Compare commits

..
Author SHA1 Message Date
fuenmao 0803d9b295 测试分支 2026-11-10 09:53:14 +08:00
fuenmao 83854ce901 新增公司盖章与骑缝章配置 2026-10-15 17:41:55 +08:00
fuenmao 0bb8263431 合同映射调整 2026-10-15 10:40:51 +08:00
fuenmao 14cfd4146e 修改正式配置 2026-10-14 16:03:12 +08:00
fuenmao 3eae5f8e5f 契约锁参数调整 2026-09-20 14:04:45 +08:00
fuenmao ff7e9c1496 相关问题修复 2026-09-18 20:44:57 +08:00
fuenmao c42d82d100 修复权限问题 2026-09-18 17:34:15 +08:00
fuenmao fa48e73b84 相关bug调整 2026-09-18 16:08:14 +08:00
fuenmao ab0248d73d 修改相关问题 2026-09-18 10:53:46 +08:00
fuenmao bd20ceac4e 修复相关bug 2026-09-17 16:04:14 +08:00
fuenmao aaec170c4f 新增必填项验证 2026-09-17 14:33:45 +08:00
fuenmao 9de05b44b8 修改相关bug 2026-09-17 11:37:39 +08:00
fuenmao e9894538e8 修复相关漏洞 2026-09-17 09:53:50 +08:00
fuenmao 20ea1bc70c 修复文件模版占位符错误问题 2026-09-15 16:43:45 +08:00
fuenmao 43a498162e 优化文档下载 2026-09-14 17:55:05 +08:00
fuenmao f004aec10f 修复相关bug 2026-09-14 15:59:37 +08:00
fuenmao 20409635d6 修复入职权限问题 2026-09-14 14:59:32 +08:00
fuenmao 1d925bd10b 员工入职模版修复 2026-09-14 10:44:30 +08:00
fuenmao 378bf7fe51 新增员工确认信息实名认证相关信息板块 2026-09-11 11:31:09 +08:00
fuenmao 571db6fc55 新增员工统计 2026-09-11 10:38:20 +08:00
fuenmao f858b04701 权限相关调整 2026-09-10 16:43:11 +08:00
fuenmao 1fd557a19e 配置调整 2026-09-09 17:08:23 +08:00
69 changed files with 2842 additions and 468 deletions
@@ -14,11 +14,11 @@
"QiyuesuoSetting": {
"Enabled": true,
"ServerUrl": "https://openapi.qiyuesuo.com",
"AppToken": "rMKU99gkts",
"AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O",
"AppToken": "mOCjDn0gMR",
"AppSecret": "5lPmSSRd7n8IhO3BvtWZJkBP1QNC59",
"SignatureVersion": "",
"CreatorName": "袁林",
"CreatorMobile": "15123135384",
"CreatorName": "李珍",
"CreatorMobile": "13629784055",
"CallbackUrl": "https://onboardx-api.cnzhyx.cn/api/open/onboarding/qiyuesuo/callback",
"SignPageCallbackUrl": "",
"SignUrlExpireMinutes": 10,
@@ -27,7 +27,7 @@
"CompanySealKeyword": "甲方(盖章):",
"CompanySealKeywordIndex": 1,
"CompanySealOffsetX": 0,
"CompanySealOffsetY": -0.12,
"CompanySealOffsetY": -0.05,
"EnableAcrossPageSeal": true,
"AcrossPageSealType": "ALL_DOCUMENT",
"TimeoutSeconds": 90
@@ -35,35 +35,35 @@ public class ObCompany : BaseEntity
/// <summary>
/// 统一社会信用代码。
/// </summary>
public string UnifiedSocialCreditCode { get; set; }
public virtual string UnifiedSocialCreditCode { get; set; }
/// <summary>
/// 法定代表人姓名。
/// </summary>
public string LegalRepresentative { get; set; }
public virtual string LegalRepresentative { get; set; }
/// <summary>
/// 工商注册地址。
/// </summary>
public string RegisteredAddress { get; set; }
public virtual string RegisteredAddress { get; set; }
/// <summary>
/// 公司联系电话。
/// </summary>
public string ContactPhone { get; set; }
public virtual string ContactPhone { get; set; }
/// <summary>
/// 契约锁默认业务分类 Id;每个签约公司独立配置。
/// </summary>
public string QiyuesuoCategoryId { get; set; }
public virtual string QiyuesuoCategoryId { get; set; }
/// <summary>
/// 契约锁企业印章 Id;每个签约公司独立配置。
/// </summary>
public string QiyuesuoSealId { get; set; }
public virtual string QiyuesuoSealId { get; set; }
/// <summary>
/// 业务备注。
/// </summary>
public string Remark { get; set; }
public virtual string Remark { get; set; }
}
@@ -52,6 +52,12 @@ public class ObContractTemplateVersion : BaseEntity
/// </summary>
public string VersionStatus { get; set; } = ContractTemplateVersionStatus.Draft;
/// <summary>本版本是否需要公司盖章;历史空值沿用需要盖章。</summary>
public bool? CompanySealEnabled { get; set; }
/// <summary>本版本是否需要骑缝章;历史空值沿用系统骑缝章配置。</summary>
public bool? AcrossPageSealEnabled { get; set; }
/// <summary>
/// 业务备注。
/// </summary>
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务部门档案,属于某家入职公司。
@@ -30,7 +30,7 @@ public class ObDepartment : BaseEntity
/// <summary>
/// 业务备注。
/// </summary>
public string Remark { get; set; }
public virtual string Remark { get; set; }
/// <summary>
/// 所属公司名称 (展示用)
@@ -52,6 +52,12 @@ public class ObElectronicSignDocument : BaseEntity
/// </summary>
public string CompanySealKeyword { get; set; }
/// <summary>发起本次签约时冻结的公司盖章要求。</summary>
public bool? CompanySealEnabled { get; set; }
/// <summary>发起本次签约时冻结的骑缝章要求。</summary>
public bool? AcrossPageSealEnabled { get; set; }
/// <summary>
/// 契约锁员工签署动作 Id。
/// </summary>
@@ -56,7 +56,7 @@ public class ObPosition : BaseEntity
/// <summary>
/// 业务备注。
/// </summary>
public string Remark { get; set; }
public virtual string Remark { get; set; }
/// <summary>
/// 所属公司名称 (展示用)
@@ -52,6 +52,12 @@ public class ObTaskContract : BaseEntity
/// </summary>
public int PrintCopies { get; set; } = 1;
/// <summary>任务创建时冻结的公司盖章要求;历史空值沿用需要盖章。</summary>
public bool? CompanySealEnabled { get; set; }
/// <summary>任务创建时冻结的骑缝章要求;历史空值沿用系统配置。</summary>
public bool? AcrossPageSealEnabled { get; set; }
/// <summary>
/// 任务合同当前状态。
/// </summary>
@@ -86,12 +86,12 @@ public class QiyuesuoSettingOptions
public decimal CompanySealOffsetY { get; set; }
/// <summary>
/// 是否在后台确认企业盖章时同时加盖骑缝章。
/// 历史版本未单独设置开关时,是否在企业盖章时加盖骑缝章;新版本显式开关优先。
/// </summary>
public bool EnableAcrossPageSeal { get; set; } = true;
/// <summary>
/// 骑缝章文档范围:ALL_DOCUMENT(全部文档合并)或 SINGLE_DOCUMENT(每份文档单独)。
/// 骑缝章方式:ALL_DOCUMENT(所选文档合并)或 SINGLE_DOCUMENT(所选文档各自独立)。
/// </summary>
public string AcrossPageSealType { get; set; } = "ALL_DOCUMENT";
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职任务审核与退回业务能力。
@@ -6,25 +6,30 @@
public class OnboardingAuditService : IOnboardingAuditService
{
private readonly SqlSugarScope _db;
private readonly IConfigService _config;
private readonly IOnboardingContractSnapshotService _snapshotService;
private readonly IOnboardingEmployeeContractSnapshotService _employeeContractSnapshotService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingAuditService(
IConfigService config,
IOnboardingContractSnapshotService snapshotService,
IOnboardingEmployeeContractSnapshotService employeeContractSnapshotService)
IOnboardingEmployeeContractSnapshotService employeeContractSnapshotService,
IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_config = config;
_snapshotService = snapshotService;
_employeeContractSnapshotService = employeeContractSnapshotService;
_dataPermission = dataPermission;
}
/// <summary>
/// 审核通过入职任务,并在同一事务中创建正式员工档案。
/// </summary>
public async Task Approve(OnboardingAuditActionInput input)
{
await OnboardingConcurrency.RunTask(_db, input.TaskId, () => ApproveCore(input), protectIdentity: true);
}
private async Task ApproveCore(OnboardingAuditActionInput input)
{
var task = await GetAuditableTask(input.TaskId);
var employeeInfo = await _db.Queryable<ObTaskEmployeeInfo>()
@@ -50,6 +55,22 @@ public class OnboardingAuditService : IOnboardingAuditService
var contractStartDate = await ResolveContractStartDate(task.Id);
var employee = employeeInfo.Adapt<ObEmployee>();
if (string.IsNullOrWhiteSpace(employee.EmployeeNo))
{
var taskContractIds = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == task.Id)
.Select(item => item.Id)
.ToListAsync();
if (taskContractIds.Count > 0)
{
employee.EmployeeNo = await _db.Queryable<ObTaskContractVariable>()
.Where(item => taskContractIds.Contains(item.TaskContractId) && item.VariableCode == "hr.EmployeeNo" && item.VariableValue != null && item.VariableValue != "")
.OrderBy(item => item.SortCode)
.Select(item => item.VariableValue)
.FirstAsync();
}
}
employee.Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
employee.CompanyId = task.CompanyId;
employee.DepartmentId = task.DepartmentId;
@@ -79,8 +100,9 @@ public class OnboardingAuditService : IOnboardingAuditService
employee.ExtraInfoJson = global::System.Text.Json.JsonSerializer.Serialize(employeeInfo);
employee.Status = CommonStatusConst.ENABLE;
employee.CreateTime = DateTime.Now;
employee.CreateUserId = UserManager.UserId;
employee.CreateUser = UserManager.Name;
// 员工档案归属于原任务创建人,而不是执行审核的人。
employee.CreateUserId = task.CreateUserId;
employee.CreateUser = task.CreateUser;
employee.UpdateTime = null;
employee.UpdateUserId = null;
employee.UpdateUser = null;
@@ -106,6 +128,24 @@ public class OnboardingAuditService : IOnboardingAuditService
await _snapshotService.Build(task.Id, employeeInfo);
await _db.Insertable(employee).ExecuteCommandAsync();
// 全局插入审计会把 CreateUserId 设置为当前审核人。管理员代审核他人任务时,
// 员工档案仍应归属于原任务创建人,否则普通人事将无法继续维护自己发起的员工。
if (task.CreateUserId.HasValue && task.CreateUserId != UserManager.UserId)
{
await _db.Updateable<ObEmployee>()
.SetColumns(item => item.CreateUserId == task.CreateUserId)
.SetColumns(item => item.CreateUser == task.CreateUser)
.Where(item => item.Id == employee.Id)
.ExecuteCommandAsync();
var ownerPersisted = await _db.Queryable<ObEmployee>()
.AnyAsync(item => item.Id == employee.Id
&& item.CreateUserId == task.CreateUserId);
if (!ownerPersisted)
throw Oops.Bah("员工档案创建人归属保存失败,请稍后重试");
}
await _employeeContractSnapshotService.Build(employee.Id, task.Id);
task.EmployeeId = employee.Id;
@@ -125,6 +165,11 @@ public class OnboardingAuditService : IOnboardingAuditService
/// 退回入职任务,保存审核意见并允许候选人补充资料。
/// </summary>
public async Task Reject(OnboardingAuditActionInput input)
{
await OnboardingConcurrency.RunTask(_db, input.TaskId, () => RejectCore(input));
}
private async Task RejectCore(OnboardingAuditActionInput input)
{
if (string.IsNullOrWhiteSpace(input.AuditOpinion))
{
@@ -171,15 +216,10 @@ public class OnboardingAuditService : IOnboardingAuditService
throw Oops.Bah("当前任务不在审核中");
}
var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds")
?? throw Oops.Bah("请配置入职审核人");
// 审核人由全局配置与公司专属配置合并得出,不依赖独立业务表。
var reviewerIds = OnboardingReviewerConfigParser.Parse(config.ConfigValue, task.CompanyId);
if (!reviewerIds.Contains(UserManager.UserId))
{
throw Oops.Bah("您不是该入职任务的审核人");
}
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
return task;
}
@@ -0,0 +1,25 @@
using SqlSugar;
namespace SimpleAdmin.Application;
/// <summary>版本盖章配置局部更新,避免覆盖另一个开关、版本状态或历史任务快照。</summary>
internal static class ContractTemplateVersionSealConfiguration
{
internal static IUpdateable<ObContractTemplateVersion> BuildUpdate(
ISqlSugarClient db,
OnboardingContractVersionSealInput input)
{
if (input.Id <= 0)
throw Oops.Bah("版本Id不能为空");
if (!input.CompanySealEnabled.HasValue && !input.AcrossPageSealEnabled.HasValue)
throw Oops.Bah("请指定需要修改的盖章开关");
var update = db.Updateable<ObContractTemplateVersion>()
.Where(item => item.Id == input.Id);
if (input.CompanySealEnabled.HasValue)
update = update.SetColumns(item => item.CompanySealEnabled == input.CompanySealEnabled);
if (input.AcrossPageSealEnabled.HasValue)
update = update.SetColumns(item => item.AcrossPageSealEnabled == input.AcrossPageSealEnabled);
return update;
}
}
@@ -0,0 +1,21 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 签署材料模板版本的时间有效性规则。
/// </summary>
internal static class ContractTemplateVersionValidity
{
internal static void ValidateRange(DateTime? effectiveAt, DateTime? expiresAt)
{
if (effectiveAt.HasValue && expiresAt.HasValue && expiresAt.Value <= effectiveAt.Value)
throw Oops.Bah("失效时间必须晚于生效时间");
}
internal static bool IsAvailable(ObContractTemplateVersion version, DateTime now)
{
return version != null
&& version.VersionStatus == ContractTemplateVersionStatus.Enabled
&& (!version.EffectiveDate.HasValue || version.EffectiveDate.Value <= now)
&& (!version.ExpiryDate.HasValue || version.ExpiryDate.Value > now);
}
}
@@ -0,0 +1,13 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务数据操作权限校验。
/// </summary>
public interface IOnboardingDataPermissionService : ITransient
{
Task<bool> CanManage(long? createUserId, string manageAllPermissionCode);
Task EnsureCanManage(long? createUserId, string manageAllPermissionCode, string dataName);
Task EnsureCanManage(IEnumerable<long?> createUserIds, string manageAllPermissionCode, string dataName);
}
@@ -0,0 +1,169 @@
using System.Data.Common;
using System.Security.Cryptography;
using System.Text;
namespace SimpleAdmin.Application;
/// <summary>
/// 所有修改同一任务的入口共用 MySQL 命名锁,适用于同一主库上的多个后端实例。
/// 锁使用独立、非池化连接,不在第三方请求期间持有业务数据库事务。
/// </summary>
internal static class OnboardingConcurrency
{
public static Task RunTask(SqlSugarScope db, long taskId, Func<Task> action,
bool transactional = false, bool protectIdentity = false)
=> RunTask(db, taskId, async () => { await action(); return true; },
transactional, protectIdentity);
public static Task<T> RunTask<T>(SqlSugarScope db, long taskId, Func<Task<T>> action,
bool transactional = false, bool protectIdentity = false)
=> Run(db, $"task:{taskId}", () => protectIdentity
? RunPerson(db, taskId, () => InTransaction(db, action, transactional))
: InTransaction(db, action, transactional));
public static Task RunEmployee(SqlSugarScope db, long employeeId, Func<Task> action)
=> Run(db, $"employee:{employeeId}", async () => { await action(); return true; });
public static Task RunPublic(SqlSugarScope db, string token, Func<Task> action,
bool transactional = false, bool protectIdentity = false)
=> RunPublic(db, token, async () => { await action(); return true; },
transactional, protectIdentity);
public static async Task<T> RunPublic<T>(SqlSugarScope db, string token, Func<Task<T>> action,
bool transactional = false, bool protectIdentity = false)
{
if (string.IsNullOrWhiteSpace(token))
throw Oops.Bah("入职链接 Token 不能为空");
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
var link = await db.Queryable<ObTaskLink>().FirstAsync(item => item.TokenHash == hash)
?? throw Oops.Bah("链接不存在");
// 此处只定位锁;被保护的方法必须在锁内重新检查链接、任务状态和归属。
return await RunTask(db, link.TaskId, action, transactional, protectIdentity);
}
public static Task RunContract(SqlSugarScope db, long contractId, Func<Task> action,
bool transactional = false)
=> RunContract(db, contractId, async () => { await action(); return true; }, transactional);
public static async Task<T> RunContract<T>(SqlSugarScope db, long contractId, Func<Task<T>> action,
bool transactional = false)
{
var contract = await db.Queryable<ObTaskContract>().InSingleAsync(contractId)
?? throw Oops.Bah("任务合同不存在");
return await RunTask(db, contract.TaskId, action, transactional);
}
public static async Task RunMaterial(SqlSugarScope db, long materialId, Func<Task> action)
{
var material = await db.Queryable<ObTaskMaterial>().InSingleAsync(materialId)
?? throw Oops.Bah("任务资料不存在");
await RunTask(db, material.TaskId, action, transactional: true);
}
private static async Task<T> RunPerson<T>(SqlSugarScope db, long taskId, Func<Task<T>> action)
{
var info = await db.Queryable<ObTaskEmployeeInfo>().FirstAsync(item => item.TaskId == taskId)
?? throw Oops.Bah("请先完整填写基本信息");
// 统一顺序:任务 -> 身份证 -> 手机;跨任务提交/建档的查重与写入也串行。
return await Run(db, "identity:" + info.IdCardNumber?.Trim().ToUpperInvariant(),
() => Run(db, "phone:" + info.Phone?.Trim(), action));
}
private static async Task<T> InTransaction<T>(SqlSugarScope db, Func<Task<T>> action, bool enabled)
{
if (!enabled || db.Ado.Transaction != null)
return await action();
await db.Ado.BeginTranAsync();
try
{
var result = await action();
await db.Ado.CommitTranAsync();
return result;
}
catch
{
await db.Ado.RollbackTranAsync();
throw;
}
}
private static Task<T> Run<T>(SqlSugarScope db, string resource, Func<Task<T>> action)
{
if (db.CurrentConnectionConfig.DbType != global::SqlSugar.DbType.MySql)
throw Oops.Bah("入职并发保护需要 MySQL 数据库");
// 使用已选择的业务主库,不能在只读副本/不同主库上分别申请锁。
var database = db.Ado.Connection.Database;
var key = Convert.ToHexString(SHA256.HashData(
Encoding.UTF8.GetBytes($"onboardx:{database}:{resource}")));
return OnboardingOperationGate.Run(key, () => Acquire(db, key), action);
}
private static async Task<IAsyncDisposable> Acquire(SqlSugarScope db, string key)
{
var connection = (DbConnection)Activator.CreateInstance(db.Ado.Connection.GetType());
var builder = new DbConnectionStringBuilder
{
ConnectionString = db.CurrentConnectionConfig.ConnectionString
};
// 确保异常关闭时会真正关闭持锁会话,不把命名锁留在连接池里。
builder["Pooling"] = false;
connection.ConnectionString = builder.ConnectionString;
try
{
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT GET_LOCK(@lockName, 0)";
command.CommandTimeout = 5;
AddKey(command, key);
var result = await command.ExecuteScalarAsync();
if (result == null || result == DBNull.Value || Convert.ToInt32(result) != 1)
throw Oops.Bah("当前数据正在处理,请稍后刷新重试");
return new NamedLock(connection, key);
}
catch
{
await connection.DisposeAsync();
throw;
}
}
private static void AddKey(DbCommand command, string key)
{
var parameter = command.CreateParameter();
parameter.ParameterName = "@lockName";
parameter.Value = key;
command.Parameters.Add(parameter);
}
private sealed class NamedLock : IAsyncDisposable
{
private readonly DbConnection _connection;
private readonly string _key;
public NamedLock(DbConnection connection, string key)
{
_connection = connection;
_key = key;
}
public async ValueTask DisposeAsync()
{
try
{
await using var command = _connection.CreateCommand();
command.CommandText = "SELECT RELEASE_LOCK(@lockName)";
command.CommandTimeout = 5;
AddKey(command, _key);
await command.ExecuteScalarAsync();
}
catch
{
// 非池化物理连接关闭会释放会话锁;释放错误不覆盖业务原异常。
}
finally
{
await _connection.DisposeAsync();
}
}
}
}
@@ -0,0 +1,50 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务数据操作权限校验。
/// 普通人事可查看全部业务数据,但只能操作本人创建的数据;拥有模块“管理全部”按钮权限的人员可操作全部数据。
/// </summary>
public class OnboardingDataPermissionService : IOnboardingDataPermissionService
{
private readonly ISysUserService _sysUserService;
public OnboardingDataPermissionService(ISysUserService sysUserService)
{
_sysUserService = sysUserService;
}
public async Task<bool> CanManage(long? createUserId, string manageAllPermissionCode)
{
if (UserManager.SuperAdmin)
return true;
if (createUserId.HasValue && createUserId.Value == UserManager.UserId)
return true;
var buttonCodes = await _sysUserService.GetButtonCodeList(UserManager.UserId);
return buttonCodes.Contains(manageAllPermissionCode);
}
public async Task EnsureCanManage(long? createUserId, string manageAllPermissionCode, string dataName)
{
if (!await CanManage(createUserId, manageAllPermissionCode))
throw Oops.Bah($"只能操作本人创建的{dataName}");
}
public async Task EnsureCanManage(
IEnumerable<long?> createUserIds,
string manageAllPermissionCode,
string dataName)
{
var owners = createUserIds.Distinct().ToList();
if (owners.Count == 0)
return;
if (UserManager.SuperAdmin || owners.All(id => id.HasValue && id.Value == UserManager.UserId))
return;
var buttonCodes = await _sysUserService.GetButtonCodeList(UserManager.UserId);
if (!buttonCodes.Contains(manageAllPermissionCode))
throw Oops.Bah($"所选数据中包含他人创建的{dataName},不能操作");
}
}
@@ -0,0 +1,35 @@
using System.Threading;
namespace SimpleAdmin.Application;
/// <summary>
/// 使同一异步调用链可重入,独立请求仍须取得外部互斥锁。
/// 不在 Acquire 中设置 AsyncLocal,避免值不能流回调用方的问题。
/// </summary>
internal static class OnboardingOperationGate
{
private sealed record Frame(string Key, Frame Parent);
private static readonly AsyncLocal<Frame> Held = new();
internal static async Task<T> Run<T>(
string key,
Func<Task<IAsyncDisposable>> acquire,
Func<Task<T>> action)
{
for (var frame = Held.Value; frame != null; frame = frame.Parent)
if (frame.Key == key)
return await action();
await using var lease = await acquire();
var previous = Held.Value;
Held.Value = new Frame(key, previous);
try
{
return await action();
}
finally
{
Held.Value = previous;
}
}
}
@@ -0,0 +1,13 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务按钮权限编码。
/// </summary>
public static class OnboardingPermissionCodes
{
public const string SetupManageAll = "onboardingSetupManageAll";
public const string EntryConfigManageAll = "onboardingEntryConfigManageAll";
public const string ContractManageAll = "onboardingContractManageAll";
public const string TaskManageAll = "onboardingTaskManageAll";
public const string EmployeeManageAll = "onboardingEmployeeManageAll";
}
@@ -1,64 +0,0 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 解析入职审核人系统配置,统一支持全局审核人和公司专属审核人。
/// </summary>
internal static class OnboardingReviewerConfigParser
{
private const string ConfigKey = "onboard.reviewer.userIds";
/// <summary>
/// 获取指定公司的全部有效审核人用户 Id。
/// </summary>
public static HashSet<long> Parse(string configValue, long companyId)
{
if (string.IsNullOrWhiteSpace(configValue))
throw Oops.Bah($"系统配置 {ConfigKey} 不能为空");
try
{
using var document = global::System.Text.Json.JsonDocument.Parse(configValue);
if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Object)
throw new global::System.Text.Json.JsonException();
var reviewerIds = new HashSet<long>();
if (document.RootElement.TryGetProperty("all", out var allReviewers))
AddReviewers(allReviewers, reviewerIds);
if (document.RootElement.TryGetProperty("company", out var companyReviewers))
{
if (companyReviewers.ValueKind != global::System.Text.Json.JsonValueKind.Object)
throw new global::System.Text.Json.JsonException();
if (companyReviewers.TryGetProperty(companyId.ToString(), out var reviewers))
AddReviewers(reviewers, reviewerIds);
}
return reviewerIds;
}
catch (global::System.Text.Json.JsonException)
{
throw Oops.Bah(
$"系统配置 {ConfigKey} 格式不正确,应为包含 all 数组和 company 对象的 JSON");
}
}
/// <summary>
/// 将 JSON 数组中的审核人 Id 加入集合,并拒绝非整数配置。
/// </summary>
private static void AddReviewers(
global::System.Text.Json.JsonElement element,
HashSet<long> reviewerIds)
{
if (element.ValueKind != global::System.Text.Json.JsonValueKind.Array)
throw new global::System.Text.Json.JsonException();
foreach (var reviewer in element.EnumerateArray())
{
if (!reviewer.TryGetInt64(out var reviewerId) || reviewerId <= 0)
throw new global::System.Text.Json.JsonException();
reviewerIds.Add(reviewerId);
}
}
}
@@ -30,19 +30,64 @@ public class OnboardingCompanyAddInput : ObCompany
/// 公司业务编码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "公司编码不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "公司编码不能超过50个字符")]
public override string CompanyCode { get; set; }
/// <summary>
/// 公司名称。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "公司名称不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "公司名称不能超过50个字符")]
public override string CompanyName { get; set; }
/// <summary>
/// 合同甲方完整法定名称。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "合同甲方全称不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(100, ErrorMessage = "合同甲方全称不能超过100个字符")]
public override string LegalName { get; set; }
/// <summary>
/// 统一社会信用代码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(18, ErrorMessage = "统一社会信用代码不能超过18位")]
public override string UnifiedSocialCreditCode { get; set; }
/// <summary>
/// 法定代表人姓名。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(20, ErrorMessage = "法定代表人不能超过20个字符")]
public override string LegalRepresentative { get; set; }
/// <summary>
/// 工商注册地址。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(150, ErrorMessage = "注册地址不能超过150个字符")]
public override string RegisteredAddress { get; set; }
/// <summary>
/// 公司联系电话。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(20, ErrorMessage = "联系电话不能超过20个字符")]
public override string ContactPhone { get; set; }
/// <summary>
/// 契约锁默认业务分类 Id;每个签约公司独立配置。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(19, ErrorMessage = "契约锁业务分类Id不能超过19位数字")]
public override string QiyuesuoCategoryId { get; set; }
/// <summary>
/// 契约锁企业印章 Id;每个签约公司独立配置。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(19, ErrorMessage = "契约锁印章Id不能超过19位数字")]
public override string QiyuesuoSealId { get; set; }
/// <summary>
/// 业务备注。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(300, ErrorMessage = "备注不能超过300个字符")]
public override string Remark { get; set; }
}
/// <summary>
@@ -5,6 +5,13 @@ namespace SimpleAdmin.Application;
/// </summary>
public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingCompanyService
{
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingCompanyService(IOnboardingDataPermissionService dataPermission)
{
_dataPermission = dataPermission;
}
/// <summary>
/// 分页查询入职业务公司。
/// </summary>
@@ -14,7 +21,7 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
.WhereIF(!string.IsNullOrWhiteSpace(input.CompanyCode), it => it.CompanyCode.Contains(input.CompanyCode))
.WhereIF(!string.IsNullOrWhiteSpace(input.CompanyName), it => it.CompanyName.Contains(input.CompanyName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyCode", "CompanyName", "LegalName", "ContactPhone"))
.OrderBy(it => it.CreateTime)
.ToPagedListAsync(input.PageNum, input.PageSize);
}
@@ -54,7 +61,11 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
public async Task Edit(OnboardingCompanyEditInput input)
{
NormalizeAndValidateQiyuesuoConfig(input);
await Detail(new BaseIdInput { Id = input.Id });
var company = await Detail(new BaseIdInput { Id = input.Id });
await _dataPermission.EnsureCanManage(
company.CreateUserId,
OnboardingPermissionCodes.SetupManageAll,
"公司");
await EnsureCodeUnique(input.CompanyCode, input.Id);
await UpdateAsync(input.Adapt<ObCompany>());
}
@@ -67,6 +78,16 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
if (input.Ids.Count == 0)
return;
var companies = await Context.Queryable<ObCompany>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (companies.Count != input.Ids.Distinct().Count())
throw Oops.Bah("公司不存在");
await _dataPermission.EnsureCanManage(
companies.Select(item => item.CreateUserId),
OnboardingPermissionCodes.SetupManageAll,
"公司");
var hasPositions = await Context.Queryable<ObPosition>()
.AnyAsync(it => input.Ids.Contains(it.CompanyId));
if (hasPositions)
@@ -117,10 +138,16 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 必须同时填写");
if (!hasCategoryId)
return;
if (!company.QiyuesuoCategoryId.All(char.IsDigit)
|| !company.QiyuesuoSealId.All(char.IsDigit))
{
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 只能填写纯数字");
}
if (!long.TryParse(company.QiyuesuoCategoryId, out _)
|| !long.TryParse(company.QiyuesuoSealId, out _))
{
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 只能填写数字");
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 超出有效数字范围(最大支持19位长整数)");
}
}
@@ -21,4 +21,9 @@ public interface IOnboardingContractDocumentService : ITransient
/// 根据已审核通过的任务合同生成并保存 DOCX 文件。
/// </summary>
Task<long> Generate(OnboardingGenerateContractInput input);
/// <summary>
/// 员工公开入口已验证一次性任务链接后,生成该链接所属的合同文件。
/// </summary>
Task<long> GenerateForPublicAccess(long taskContractId, long taskId);
}
@@ -7,11 +7,15 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
{
private readonly SqlSugarScope _db;
private readonly IFileService _fileService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingContractDocumentService(IFileService fileService)
public OnboardingContractDocumentService(
IFileService fileService,
IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_fileService = fileService;
_dataPermission = dataPermission;
}
/// <summary>
@@ -149,13 +153,14 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
var versions = await _db.Queryable<ObContractTemplateVersion>()
.Where(item => versionIds.Contains(item.Id))
.ToListAsync();
var now = DateTime.Now;
var versionsValid = versions.Count == versionIds.Count
&& contractConfigs.All(config => versions.Any(version =>
version.Id == config.ContractTemplateVersionId
&& version.TemplateId == config.ContractTemplateId
&& version.VersionStatus == ContractTemplateVersionStatus.Enabled));
&& ContractTemplateVersionValidity.IsAvailable(version, now)));
if (!versionsValid)
throw Oops.Bah("入职模板包含不存在、未启用或归属错误的合同版本");
throw Oops.Bah("入职模板包含不存在、未生效、已失效、未启用或归属错误的签署材料版本");
var inputs = input.ContractVariables
?? new List<OnboardingInitialContractVariableInput>();
@@ -293,11 +298,46 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
/// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。
/// </summary>
public async Task<long> Generate(OnboardingGenerateContractInput input)
{
return await OnboardingConcurrency.RunContract(_db, input.TaskContractId, () => GenerateCore(input));
}
private async Task<long> GenerateCore(OnboardingGenerateContractInput input)
{
var contract = await _db.Queryable<ObTaskContract>().InSingleAsync(input.TaskContractId)
?? throw Oops.Bah("任务合同不存在");
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(contract.TaskId)
?? throw Oops.Bah("入职任务不存在");
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
return await GenerateCore(contract, task);
}
/// <inheritdoc />
public async Task<long> GenerateForPublicAccess(long taskContractId, long taskId)
{
return await OnboardingConcurrency.RunTask(_db, taskId, () => GenerateForPublicAccessCore(taskContractId, taskId));
}
private async Task<long> GenerateForPublicAccessCore(long taskContractId, long taskId)
{
var contract = await _db.Queryable<ObTaskContract>()
.FirstAsync(item => item.Id == taskContractId && item.TaskId == taskId)
?? throw Oops.Bah("合同不存在");
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
?? throw Oops.Bah("入职任务不存在");
return await GenerateCore(contract, task);
}
/// <summary>
/// 执行已经完成调用方权限或公开链接归属校验的合同生成逻辑。
/// </summary>
private async Task<long> GenerateCore(ObTaskContract contract, ObOnboardingTask task)
{
var statusAllowsGeneration = task.TaskStatus == OnboardingTaskStatus.PendingFill
|| task.TaskStatus == OnboardingTaskStatus.Rejected
|| task.TaskStatus == OnboardingTaskStatus.Reviewing
@@ -332,7 +372,7 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
.FirstOrDefault(definition => string.IsNullOrWhiteSpace(
variables.FirstOrDefault(variable => variable.VariableCode == definition.VariableCode)?.VariableValue));
if (missing != null)
throw Oops.Bah($"合同变量“{missing.VariableName}”尚未填写,暂时不能预览合同");
throw Oops.Bah($"合同“{contract.ContractName}”的变量“{missing.VariableName}”尚未填写,暂时不能预览合同");
var sourceResult = await _fileService.Download(new BaseIdInput { Id = source.Id })
?? throw Oops.Bah("无法读取合同源文件");
@@ -347,8 +387,19 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
item => item.VariableValue ?? string.Empty,
StringComparer.OrdinalIgnoreCase));
var generatedAt = DateTime.Now;
// 在文件名中加入生成批次,避免手机系统把同名的新合同误认为历史下载文件。
var name = $"{task.TaskNo}_{contract.ContractName}_{generatedAt:yyyyMMddHHmmssfff}.docx";
var employeeInfo = await _db.Queryable<ObTaskEmployeeInfo>()
.FirstAsync(item => item.TaskId == task.Id);
var employeeName = string.IsNullOrWhiteSpace(employeeInfo?.EmployeeName)
? "新员工"
: employeeInfo.EmployeeName.Trim();
var docType = string.IsNullOrWhiteSpace(contract.ContractType)
? (string.IsNullOrWhiteSpace(contract.ContractName) ? "签署材料" : contract.ContractName.Trim())
: contract.ContractType.Trim();
var invalidChars = global::System.IO.Path.GetInvalidFileNameChars();
var safeEmployeeName = new string(employeeName.Select(character => invalidChars.Contains(character) ? '_' : character).ToArray());
var safeDocType = new string(docType.Select(character => invalidChars.Contains(character) ? '_' : character).ToArray());
// 按照规范:编号_员工姓名_文档类型.docx
var name = $"{task.TaskNo}_{safeEmployeeName}_{safeDocType}.docx";
var fileId = await _fileService.StoreGeneratedFile(
SysDictConst.FILE_ENGINE_ALIYUN_OSS,
name,
@@ -19,6 +19,14 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
long taskId,
ObTaskEmployeeInfo employeeInfo = null,
bool invalidateGeneratedPreview = false)
{
await OnboardingConcurrency.RunTask(_db, taskId, () => BuildCore(taskId, employeeInfo, invalidateGeneratedPreview), transactional: true);
}
private async Task BuildCore(
long taskId,
ObTaskEmployeeInfo employeeInfo = null,
bool invalidateGeneratedPreview = false)
{
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
?? throw Oops.Bah("入职任务不存在");
@@ -224,7 +232,8 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
{
return sourceField.ToUpperInvariant() switch
{
"COMPANYCODE" or "COMPANY_CODE" => nameof(ObCompany.CompanyCode),
// 内置合同变量 CompanyCode 表示统一社会信用代码,保留旧 DOCX 占位符兼容。
"COMPANYCODE" or "COMPANY_CODE" => nameof(ObCompany.UnifiedSocialCreditCode),
"COMPANYNAME" or "COMPANY_NAME" => nameof(ObCompany.CompanyName),
"LEGALNAME" or "LEGAL_NAME" or "COMPANYLEGALNAME" => nameof(ObCompany.LegalName),
"UNIFIEDSOCIALCREDITCODE" or "UNIFIED_SOCIAL_CREDIT_CODE" => nameof(ObCompany.UnifiedSocialCreditCode),
@@ -35,6 +35,7 @@ public static class DocxTemplateProcessor
foreach (var paragraph in document.Descendants(WordNamespace + "p"))
{
var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value));
ValidatePlaceholderSyntax(text);
foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text))
codes.Add(match.Groups["code"].Value.Trim());
}
@@ -52,6 +53,72 @@ public static class DocxTemplateProcessor
}
}
/// <summary>
/// 占位符提取正则只会匹配正确格式,因此必须先主动检查错误格式,
/// 避免缺少花括号或变量编码写错时被静默忽略并继续创建版本。
/// </summary>
private static void ValidatePlaceholderSyntax(string text)
{
if (string.IsNullOrEmpty(text))
return;
if (text.Contains("{{", StringComparison.Ordinal)
|| text.Contains("}}", StringComparison.Ordinal))
{
throw Oops.Bah(
"DOCX 占位符格式错误:检测到中文全角花括号。请使用英文半角双花括号,例如 {{employee.EmployeeName}}。");
}
if (text.Contains("{{{", StringComparison.Ordinal)
|| text.Contains("}}}", StringComparison.Ordinal))
{
throw Oops.Bah(
"DOCX 占位符格式错误:检测到多余的花括号。占位符必须使用成对的英文半角双花括号,例如 {{employee.EmployeeName}}。");
}
var searchStart = 0;
while (searchStart < text.Length)
{
var openIndex = text.IndexOf("{{", searchStart, StringComparison.Ordinal);
var closeIndex = text.IndexOf("}}", searchStart, StringComparison.Ordinal);
if (closeIndex >= 0 && (openIndex < 0 || closeIndex < openIndex))
{
throw Oops.Bah(
$"DOCX 占位符格式错误:{BuildPlaceholderPreview(text, closeIndex)} 缺少对应的起始符“{{{{”。正确格式示例:{{{{employee.EmployeeName}}}}。");
}
if (openIndex < 0)
return;
closeIndex = text.IndexOf("}}", openIndex + 2, StringComparison.Ordinal);
if (closeIndex < 0)
{
throw Oops.Bah(
$"DOCX 占位符格式错误:{BuildPlaceholderPreview(text, openIndex)} 缺少结束符“}}}}”。正确格式示例:{{{{employee.EmployeeName}}}}。");
}
var placeholder = text.Substring(openIndex, closeIndex + 2 - openIndex);
var match = PlaceholderRegex.Match(placeholder);
if (!match.Success || match.Length != placeholder.Length)
{
throw Oops.Bah(
$"DOCX 占位符格式错误:“{placeholder}”。变量编码只能包含英文字母、数字、下划线、点和短横线,并且必须使用成对的英文半角双花括号。");
}
searchStart = closeIndex + 2;
}
}
private static string BuildPlaceholderPreview(string text, int markerIndex)
{
const int maxLength = 60;
var preview = text[markerIndex..].Trim();
if (preview.Length > maxLength)
preview = preview[..maxLength] + "…";
return $"“{preview}”";
}
/// <summary>
/// 使用任务变量快照替换 DOCX 中的占位符,支持占位符跨多个 Word 文本节点。
/// </summary>
@@ -46,15 +46,21 @@ public class OnboardingContractVersionAddInput
public string FileHash { get; set; }
/// <summary>
/// 生效日期。
/// 生效时间。
/// </summary>
public DateTime? EffectiveDate { get; set; }
/// <summary>
/// 失效日期。
/// 失效时间。
/// </summary>
public DateTime? ExpiryDate { get; set; }
/// <summary>本版本是否需要公司盖章;旧客户端未传时沿用原规则。</summary>
public bool? CompanySealEnabled { get; set; }
/// <summary>本版本是否需要骑缝章;旧客户端未传时沿用系统配置。</summary>
public bool? AcrossPageSealEnabled { get; set; }
/// <summary>
/// 业务备注。
/// </summary>
@@ -78,3 +84,19 @@ public class OnboardingContractVersionStatusInput
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "版本状态不能为空")]
public string VersionStatus { get; set; }
}
/// <summary>
/// 修改合同模板版本的盖章配置;未传的开关保持不变。
/// </summary>
public class OnboardingContractVersionSealInput
{
/// <summary>合同版本 Id。</summary>
[IdNotNull(ErrorMessage = "版本Id不能为空")]
public long Id { get; set; }
/// <summary>是否需要公司盖章。</summary>
public bool? CompanySealEnabled { get; set; }
/// <summary>是否需要骑缝章。</summary>
public bool? AcrossPageSealEnabled { get; set; }
}
@@ -50,6 +50,11 @@ public interface IOnboardingContractTemplateService : ITransient
/// </summary>
Task SetVersionStatus(OnboardingContractVersionStatusInput input);
/// <summary>
/// 修改合同模板版本的盖章配置,仅影响后续新建任务。
/// </summary>
Task SetVersionSeals(OnboardingContractVersionSealInput input);
/// <summary>
/// 删除未被入职模板或任务使用的合同版本。
/// </summary>
@@ -6,10 +6,14 @@ namespace SimpleAdmin.Application;
public class OnboardingContractTemplateService : DbRepository<ObContractTemplate>, IOnboardingContractTemplateService
{
private readonly IFileService _fileService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingContractTemplateService(IFileService fileService)
public OnboardingContractTemplateService(
IFileService fileService,
IOnboardingDataPermissionService dataPermission)
{
_fileService = fileService;
_dataPermission = dataPermission;
}
/// <summary>
@@ -36,7 +40,7 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
}
var page = await query
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentId", "TemplateCode", "TemplateName", "ContractType"))
.OrderByDescending(it => it.CreateTime)
.OrderBy(it => it.TemplateName)
.ToPagedListAsync(input.PageNum, input.PageSize);
@@ -75,7 +79,21 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
if (candidates.Count == 0)
return candidates;
var now = DateTime.Now;
var candidateIds = candidates.Select(item => item.Id).ToList();
var availableVersionTemplateIds = (await Context.Queryable<ObContractTemplateVersion>()
.Where(item => candidateIds.Contains(item.TemplateId))
.ToListAsync())
.Where(item => ContractTemplateVersionValidity.IsAvailable(item, now))
.Select(item => item.TemplateId)
.ToHashSet();
candidates = candidates
.Where(item => availableVersionTemplateIds.Contains(item.Id))
.ToList();
if (candidates.Count == 0)
return candidates;
candidateIds = candidates.Select(item => item.Id).ToList();
var relations = await Context.Queryable<ObContractTemplatePosition>()
.Where(item => candidateIds.Contains(item.ContractTemplateId))
.ToListAsync();
@@ -122,10 +140,14 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// </summary>
public async Task Edit(OnboardingContractTemplateEditInput input)
{
await Detail(new BaseIdInput { Id = input.Id });
var template = await Detail(new BaseIdInput { Id = input.Id });
await _dataPermission.EnsureCanManage(
template.CreateUserId,
OnboardingPermissionCodes.ContractManageAll,
"合同模板");
NormalizeInput(input);
await ValidateBusinessScope(input.CompanyId, input.DepartmentId, input.PositionIds);
await ValidateReferencedOnboardingTemplates(input);
await ValidateReferencedOnboardingTemplates(input, template.Status);
await EnsureCodeUnique(input.TemplateCode, input.Id);
var entity = BuildEntity(input.Id, input);
@@ -156,6 +178,16 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
if (input.Ids.Count == 0)
return;
var templates = await Context.Queryable<ObContractTemplate>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (templates.Count != input.Ids.Distinct().Count())
throw Oops.Bah("合同模板不存在");
await _dataPermission.EnsureCanManage(
templates.Select(item => item.CreateUserId),
OnboardingPermissionCodes.ContractManageAll,
"合同模板");
var hasOnboardingTemplates = await Context.Queryable<ObOnboardingTemplateContract>()
.AnyAsync(it => input.Ids.Contains(it.ContractTemplateId));
if (hasOnboardingTemplates)
@@ -184,10 +216,6 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
.ExecuteCommandAsync();
}
await Context.Deleteable<ObContractScope>()
.Where(it => input.Ids.Contains(it.TemplateId))
.ExecuteCommandAsync();
await Context.Deleteable<ObContractTemplatePosition>()
.Where(item => input.Ids.Contains(item.ContractTemplateId))
.ExecuteCommandAsync();
@@ -209,6 +237,7 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
{
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id)
?? throw Oops.Bah("合同版本不存在");
await EnsureTemplateCanManage(version.TemplateId);
var isUsedInOnboardingTemplate = await Context.Queryable<ObOnboardingTemplateContract>()
.AnyAsync(it => it.ContractTemplateVersionId == version.Id);
@@ -226,9 +255,6 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
await Context.Deleteable<ObTemplateVariable>()
.Where(it => it.TemplateVersionId == version.Id)
.ExecuteCommandAsync();
await Context.Deleteable<ObContractScope>()
.Where(it => it.TemplateVersionId == version.Id)
.ExecuteCommandAsync();
await Context.Deleteable<ObContractTemplateVersion>()
.In(version.Id)
.ExecuteCommandAsync();
@@ -246,11 +272,21 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// </summary>
public async Task<SqlSugarPagedList<ObContractTemplateVersion>> VersionPage(OnboardingContractVersionPageInput input)
{
return await Context.Queryable<ObContractTemplateVersion>()
var page = await Context.Queryable<ObContractTemplateVersion>()
.Where(it => it.TemplateId == input.TemplateId)
.WhereIF(!string.IsNullOrWhiteSpace(input.VersionStatus), it => it.VersionStatus == input.VersionStatus)
.OrderByDescending(it => it.CreateTime)
.ToPagedListAsync(input.PageNum, input.PageSize);
// 仅解析返回值,不回写历史空值,使列表开关显示真实生效的规则。
var setting = App.GetConfig<QiyuesuoSettingOptions>("QiyuesuoSetting", true)
?? new QiyuesuoSettingOptions();
foreach (var version in page.List)
{
version.CompanySealEnabled = QiyuesuoContractRequestBuilder.CompanySealEnabled(version.CompanySealEnabled);
version.AcrossPageSealEnabled = QiyuesuoContractRequestBuilder.AcrossPageSealEnabled(version.AcrossPageSealEnabled, setting);
}
return page;
}
/// <summary>
@@ -258,7 +294,12 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// </summary>
public async Task AddVersion(OnboardingContractVersionAddInput input)
{
await Detail(new BaseIdInput { Id = input.TemplateId });
ContractTemplateVersionValidity.ValidateRange(input.EffectiveDate, input.ExpiryDate);
var template = await Detail(new BaseIdInput { Id = input.TemplateId });
await _dataPermission.EnsureCanManage(
template.CreateUserId,
OnboardingPermissionCodes.ContractManageAll,
"合同模板");
var file = await Context.Queryable<SysFile>().InSingleAsync(input.SourceDocxFileId)
?? throw Oops.Bah("DOCX 源文件不存在");
if (!string.Equals(file.Suffix, "docx", StringComparison.OrdinalIgnoreCase))
@@ -337,9 +378,25 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id)
?? throw Oops.Bah("模板版本不存在");
await EnsureTemplateCanManage(version.TemplateId);
if (version.VersionStatus == ContractTemplateVersionStatus.Enabled
&& input.VersionStatus == ContractTemplateVersionStatus.Disabled)
{
var referencedNames = await GetEnabledOnboardingTemplateNames(
version.TemplateId,
version.Id);
if (referencedNames.Count > 0)
{
throw Oops.Bah(
$"该签署材料版本正在被启用中的入职模板{FormatTemplateNames(referencedNames)}引用,不能停用,请先调整或停用这些入职模板");
}
}
if (input.VersionStatus == ContractTemplateVersionStatus.Enabled)
{
ContractTemplateVersionValidity.ValidateRange(version.EffectiveDate, version.ExpiryDate);
if (version.ExpiryDate.HasValue && version.ExpiryDate.Value <= DateTime.Now)
throw Oops.Bah("该签署材料版本已经失效,不能启用");
var hasUnresolvedVariables = await Context.Queryable<ObTemplateVariable>()
.AnyAsync(item => item.TemplateVersionId == input.Id
&& item.VariableType == ContractVariableType.Unresolved);
@@ -348,7 +405,21 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
}
version.VersionStatus = input.VersionStatus;
await Context.Updateable(version).ExecuteCommandAsync();
await Context.Updateable(version)
.UpdateColumns(item => new { item.VersionStatus, item.UpdateTime, item.UpdateUserId, item.UpdateUser })
.ExecuteCommandAsync();
}
/// <summary>
/// 修改版本盖章开关;任务和电子签约已经保存独立快照,不随版本变更。
/// </summary>
public async Task SetVersionSeals(OnboardingContractVersionSealInput input)
{
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id)
?? throw Oops.Bah("模板版本不存在");
await EnsureTemplateCanManage(version.TemplateId);
await ContractTemplateVersionSealConfiguration.BuildUpdate(Context, input).ExecuteCommandAsync();
}
/// <summary>
@@ -374,6 +445,11 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
if (!string.Equals(variable.VariableCode, input.VariableCode, StringComparison.Ordinal))
throw Oops.Bah("DOCX 占位符编码由系统识别,不能修改");
var version = await Context.Queryable<ObContractTemplateVersion>()
.InSingleAsync(variable.TemplateVersionId)
?? throw Oops.Bah("模板版本不存在");
await EnsureTemplateCanManage(version.TemplateId);
var isUsedInTask = await Context.Queryable<ObTaskContract>()
.AnyAsync(it => it.TemplateVersionId == input.TemplateVersionId);
if (isUsedInTask)
@@ -384,6 +460,16 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
await Context.Updateable(entity).ExecuteCommandAsync();
}
private async Task EnsureTemplateCanManage(long templateId)
{
var template = await Context.Queryable<ObContractTemplate>().InSingleAsync(templateId)
?? throw Oops.Bah("合同模板不存在");
await _dataPermission.EnsureCanManage(
template.CreateUserId,
OnboardingPermissionCodes.ContractManageAll,
"合同模板");
}
private static ObTemplateVariable BuildDetectedVariable(long versionId, string code, ObVariableDefinition def, int index)
{
var separatorIndex = code.IndexOf('.');
@@ -500,7 +586,8 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// 防止修改合同模板归属后破坏已经保存的入职模板配置。
/// </summary>
private async Task ValidateReferencedOnboardingTemplates(
OnboardingContractTemplateEditInput input)
OnboardingContractTemplateEditInput input,
string currentStatus)
{
var relations = await Context.Queryable<ObOnboardingTemplateContract>()
.Where(item => item.ContractTemplateId == input.Id)
@@ -515,6 +602,21 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
var onboardingTemplates = await Context.Queryable<ObOnboardingTemplate>()
.Where(item => onboardingTemplateIds.Contains(item.Id))
.ToListAsync();
if (string.Equals(currentStatus, "enable", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(input.Status, "enable", StringComparison.OrdinalIgnoreCase))
{
var enabledTemplateNames = onboardingTemplates
.Where(item => !item.IsDelete && item.Status == "enable")
.Select(item => item.TemplateName)
.Distinct()
.OrderBy(item => item)
.ToList();
if (enabledTemplateNames.Count > 0)
{
throw Oops.Bah(
$"该签署材料模板正在被启用中的入职模板{FormatTemplateNames(enabledTemplateNames)}引用,不能停用,请先调整或停用这些入职模板");
}
}
var positionRelations = await Context.Queryable<ObOnboardingTemplatePosition>()
.Where(item => onboardingTemplateIds.Contains(item.OnboardingTemplateId))
.ToListAsync();
@@ -539,6 +641,39 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
}
}
private async Task<List<string>> GetEnabledOnboardingTemplateNames(
long contractTemplateId,
long? contractTemplateVersionId = null)
{
var relations = await Context.Queryable<ObOnboardingTemplateContract>()
.Where(item => item.ContractTemplateId == contractTemplateId)
.WhereIF(contractTemplateVersionId.HasValue,
item => item.ContractTemplateVersionId == contractTemplateVersionId.Value)
.ToListAsync();
if (relations.Count == 0)
return new List<string>();
var onboardingTemplateIds = relations
.Select(item => item.OnboardingTemplateId)
.Distinct()
.ToList();
var names = await Context.Queryable<ObOnboardingTemplate>()
.Where(item => onboardingTemplateIds.Contains(item.Id)
&& !item.IsDelete
&& item.Status == "enable")
.Select(item => item.TemplateName)
.ToListAsync();
return names
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct()
.OrderBy(item => item)
.ToList();
}
private static string FormatTemplateNames(IEnumerable<string> templateNames)
{
return string.Join("、", templateNames.Select(item => $"“{item}”"));
}
/// <summary>
/// 构建合同模板主档案实体。
/// </summary>
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务部门分页查询参数。
@@ -41,13 +41,21 @@ public class OnboardingDepartmentAddInput : ObDepartment
/// 部门业务编码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "部门编码不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "部门编码不能超过50个字符")]
public override string DepartmentCode { get; set; }
/// <summary>
/// 部门名称。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "部门名称不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "部门名称不能超过50个字符")]
public override string DepartmentName { get; set; }
/// <summary>
/// 业务备注。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(200, ErrorMessage = "备注不能超过200个字符")]
public override string Remark { get; set; }
}
/// <summary>
@@ -1,10 +1,17 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职业务部门业务能力。
/// </summary>
public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardingDepartmentService
{
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingDepartmentService(IOnboardingDataPermissionService dataPermission)
{
_dataPermission = dataPermission;
}
/// <summary>
/// 分页查询入职业务部门。
/// </summary>
@@ -15,7 +22,7 @@ public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardi
.WhereIF(!string.IsNullOrWhiteSpace(input.DepartmentCode), it => it.DepartmentCode.Contains(input.DepartmentCode))
.WhereIF(!string.IsNullOrWhiteSpace(input.DepartmentName), it => it.DepartmentName.Contains(input.DepartmentName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentCode", "DepartmentName", "SortCode"))
.OrderBy(it => it.SortCode)
.OrderBy(it => it.DepartmentName)
.ToPagedListAsync(input.PageNum, input.PageSize);
@@ -78,6 +85,10 @@ public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardi
{
NormalizeInput(input);
var department = await Detail(new BaseIdInput { Id = input.Id });
await _dataPermission.EnsureCanManage(
department.CreateUserId,
OnboardingPermissionCodes.SetupManageAll,
"部门");
await EnsureCompanyCanChange(department, input.CompanyId);
await EnsureCompanyExists(input.CompanyId);
await EnsureCodeUnique(input.CompanyId, input.DepartmentCode, input.Id);
@@ -92,6 +103,16 @@ public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardi
if (input.Ids.Count == 0)
return;
var departments = await Context.Queryable<ObDepartment>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (departments.Count != input.Ids.Distinct().Count())
throw Oops.Bah("部门不存在");
await _dataPermission.EnsureCanManage(
departments.Select(item => item.CreateUserId),
OnboardingPermissionCodes.SetupManageAll,
"部门");
var hasPositions = await Context.Queryable<ObPosition>()
.AnyAsync(it => it.DepartmentId.HasValue && input.Ids.Contains(it.DepartmentId.Value));
if (hasPositions)
@@ -164,4 +164,10 @@ public class OnboardingElectronicSignDocumentOutput
/// <summary>当前管理员流程中是否已生成过员工签后预览。</summary>
public bool EmployeeSignedPreviewed { get; set; }
/// <summary>本份文件是否需要公司盖章。</summary>
public bool CompanySealEnabled { get; set; }
/// <summary>本份文件是否需要骑缝章。</summary>
public bool AcrossPageSealEnabled { get; set; }
}
@@ -17,20 +17,20 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
private readonly IQiyuesuoClient _qiyuesuoClient;
private readonly IOnboardingContractDocumentService _contractDocumentService;
private readonly IFileService _fileService;
private readonly IConfigService _configService;
private readonly IOnboardingDataPermissionService _dataPermission;
private readonly QiyuesuoSettingOptions _setting;
public OnboardingElectronicSignService(
IQiyuesuoClient qiyuesuoClient,
IOnboardingContractDocumentService contractDocumentService,
IFileService fileService,
IConfigService configService)
IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_qiyuesuoClient = qiyuesuoClient;
_contractDocumentService = contractDocumentService;
_fileService = fileService;
_configService = configService;
_dataPermission = dataPermission;
_setting = App.GetConfig<QiyuesuoSettingOptions>(
"QiyuesuoSetting",
true)
@@ -41,13 +41,19 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
public async Task<OnboardingElectronicSignDetailOutput> Detail(
BaseIdInput input)
{
var task = await GetAuthorizedTask(input.Id);
var task = await GetTask(input.Id);
return await BuildDetail(task);
}
/// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> Initiate(
OnboardingElectronicSignTaskInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => InitiateCore(input));
}
private async Task<OnboardingElectronicSignDetailOutput> InitiateCore(
OnboardingElectronicSignTaskInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
ValidateTaskCanSign(task);
@@ -99,6 +105,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> Reinitiate(
OnboardingElectronicSignReinitiateInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => ReinitiateCore(input));
}
private async Task<OnboardingElectronicSignDetailOutput> ReinitiateCore(
OnboardingElectronicSignReinitiateInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
ValidateTaskCanSign(task);
@@ -165,8 +177,14 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
throw Oops.Bah($"公司“{companyName}”未配置契约锁默认业务分类 Id,请先到公司与岗位中编辑公司资料");
if (string.IsNullOrWhiteSpace(sealId))
throw Oops.Bah($"公司“{companyName}”未配置契约锁企业印章 Id,请先到公司与岗位中编辑公司资料");
if (!categoryId.All(char.IsDigit)
|| !sealId.All(char.IsDigit))
{
throw Oops.Bah($"公司“{companyName}”的契约锁业务分类或企业印章 Id 只能填写纯数字");
}
if (!long.TryParse(categoryId, out _) || !long.TryParse(sealId, out _))
throw Oops.Bah($"公司“{companyName}”的契约锁业务分类或印章 Id 格式不正确");
throw Oops.Bah($"公司“{companyName}”的契约锁业务分类或企业印章 Id 超出有效范围(最大支持19位长整数)");
var taskContracts = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == task.Id)
@@ -256,6 +274,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> Sync(
OnboardingElectronicSignTaskInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => SyncCore(input));
}
private async Task<OnboardingElectronicSignDetailOutput> SyncCore(
OnboardingElectronicSignTaskInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
@@ -266,6 +290,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> GetSignLink(
OnboardingElectronicSignTaskInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => GetSignLinkCore(input));
}
private async Task<OnboardingElectronicSignDetailOutput> GetSignLinkCore(
OnboardingElectronicSignTaskInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
@@ -290,7 +320,13 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
public async Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocument(
OnboardingElectronicSignPreviewInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => PreviewEmployeeSignedDocumentCore(input));
}
private async Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocumentCore(
OnboardingElectronicSignPreviewInput input)
{
var task = await GetTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
// 不信任前端缓存状态。每次预览前重新查询契约锁,确保员工确实已完成签署。
@@ -346,6 +382,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> CompanySeal(
OnboardingElectronicSignTaskInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.TaskId, () => CompanySealCore(input));
}
private async Task<OnboardingElectronicSignDetailOutput> CompanySealCore(
OnboardingElectronicSignTaskInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
@@ -378,7 +420,29 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
$"请先逐份查看员工已签文件:{string.Join("、", notPreviewed)}");
}
// 发出前持久化占用。进程退出/响应丢失也不能自动重发可能已送达的盖章命令。
var alreadySubmitted = await _db.Queryable<ObElectronicSignEvent>()
.AnyAsync(item => item.SignProcessId == process.Id
&& item.EventSource == AdminCompanySealEventSource
&& (item.EventType == "company_seal_submitted"
|| item.EventType == "company_seal_dispatch_started"));
if (alreadySubmitted)
throw Oops.Bah("企业盖章命令已发送或结果待确认,请先刷新签署状态并核实契约锁平台,系统不会重复盖章");
var companyName = await GetCompanyLegalName(task);
await RecordAdminOperation(
process,
AdminCompanySealEventSource,
"company_seal_dispatch_started",
JsonSerializer.Serialize(new
{
process.ProviderContractId,
companyName,
process.SealId,
operatorUserId = UserManager.UserId,
operatorName = UserManager.Name
}),
"已占用盖章请求;发送结果待确认,禁止自动重复发送");
try
{
await _qiyuesuoClient.CompanySign(
@@ -406,10 +470,10 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
return await BuildDetail(task, process);
}
process.LastError = $"企业盖章失败:{LimitError(signException.Message)}";
process.LastError = $"企业盖章结果待核实,已阻止重复发送:{LimitError(signException.Message)}";
process.UpdateTime = DateTime.Now;
await _db.Updateable(process).ExecuteCommandAsync();
throw;
throw Oops.Bah("企业盖章未确认成功,请刷新状态并核实契约锁平台;为避免重复盖章,不能直接重发。原因:" + LimitError(signException.Message));
}
await RecordAdminOperation(
@@ -441,6 +505,11 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc />
public async Task Notice(OnboardingElectronicSignTaskInput input)
{
await OnboardingConcurrency.RunTask(_db, input.TaskId, () => NoticeCore(input));
}
private async Task NoticeCore(OnboardingElectronicSignTaskInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
@@ -455,6 +524,11 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc />
public async Task Invalid(OnboardingElectronicSignInvalidInput input)
{
await OnboardingConcurrency.RunTask(_db, input.TaskId, () => InvalidCore(input));
}
private async Task InvalidCore(OnboardingElectronicSignInvalidInput input)
{
var task = await GetAuthorizedTask(input.TaskId);
var process = await GetRequiredProcess(task.Id);
@@ -478,6 +552,21 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// <inheritdoc />
public async Task HandleCallback(string payloadJson)
{
if (string.IsNullOrWhiteSpace(payloadJson))
throw Oops.Bah("契约锁回调内容为空");
using var json = JsonDocument.Parse(payloadJson);
var process = await FindProcess(FindString(json.RootElement, "contractId"), FindString(json.RootElement, "bizId"));
if (process == null)
{
await HandleCallbackCore(payloadJson);
return;
}
// 只在锁外定位任务;锁内重新读取事件和流程,重复回调不会同时执行归档。
await OnboardingConcurrency.RunTask(_db, process.TaskId, () => HandleCallbackCore(payloadJson, taskLockHeld: true));
}
private async Task HandleCallbackCore(string payloadJson, bool taskLockHeld = false)
{
if (string.IsNullOrWhiteSpace(payloadJson))
throw Oops.Bah("契约锁回调内容为空");
@@ -504,6 +593,13 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
"contractStatus",
"status");
var process = await FindProcess(providerContractId, bizId);
// 回调首次定位时流程可能还在创建;若此刻才匹配到,先取得任务锁再重新执行。
if (process != null && !taskLockHeld)
{
await OnboardingConcurrency.RunTask(_db, process.TaskId,
() => HandleCallbackCore(payloadJson, taskLockHeld: true));
return;
}
if (callbackEvent == null)
{
callbackEvent = new ObElectronicSignEvent
@@ -549,7 +645,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
try
{
process.LastCallbackAt = DateTime.Now;
await _db.Updateable(process).ExecuteCommandAsync();
await _db.Updateable(process).UpdateColumns(item => item.LastCallbackAt).ExecuteCommandAsync();
// 明文回调只作为刷新触发器,最终状态重新向契约锁查询,伪造回调不能改变业务状态。
await SyncProcess(process, "callback");
callbackEvent.ProviderStatus = process.ProviderStatus;
@@ -709,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";
@@ -764,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,
@@ -783,7 +884,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
}
/// <summary>
/// 为每份文档分别配置员工签名与企业盖章位置,然后一次性发送整个流程。
/// 全部文档配置员工签名,按每份文件的快照配置企业盖章及骑缝章位置。
/// </summary>
private async Task SendProcess(
ObElectronicSignProcess process,
@@ -795,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<QiyuesuoStamperInput>();
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
@@ -939,7 +1001,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
ObElectronicSignProcess process,
QiyuesuoContractResult providerContract)
{
var localStatus = ResolveSignStatus(providerContract);
var localStatus = OnboardingSignStateGuard.Resolve(process.SignStatus, ResolveSignStatus(providerContract));
process.ProviderStatus = providerContract.Status;
process.SignStatus = localStatus;
if (localStatus == ElectronicSignStatus.Completed
@@ -950,7 +1012,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
process.SignUrlExpireAt = null;
await _db.Updateable<ObTaskContract>()
.SetColumns(item => item.ContractStatus == TaskContractStatus.ElectronicSigned)
.Where(item => item.TaskId == process.TaskId)
.Where(item => item.TaskId == process.TaskId && process.IsCurrent == true
&& item.ContractStatus != TaskContractStatus.Archived)
.ExecuteCommandAsync();
}
else if (IsTerminal(localStatus))
@@ -974,24 +1037,25 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
foreach (var document in documents)
{
if (document.SignedPdfFileId.HasValue)
continue;
var signedPdf = await _qiyuesuoClient.DownloadDocument(
document.ProviderDocumentId);
var pdfFileId = await _fileService.StoreGeneratedFile(
if (!document.SignedPdfFileId.HasValue)
{
var signedPdf = await _qiyuesuoClient.DownloadDocument(document.ProviderDocumentId);
document.SignedPdfFileId = await _fileService.StoreGeneratedFile(
SysDictConst.FILE_ENGINE_ALIYUN_OSS,
$"{SafeFileName(process.BizId)}_{document.DocumentSort:D2}_{SafeFileName(document.DocumentName)}_已签署.pdf",
signedPdf);
document.SignedPdfFileId = pdfFileId;
document.UpdateTime = DateTime.Now;
await _db.Updateable(document).ExecuteCommandAsync();
}
// 中断重试时仍回填合同指针,不能因文档已落库而跳过未完成的关联更新。
var pdfFileId = document.SignedPdfFileId.Value;
await _db.Updateable<ObTaskContract>()
.SetColumns(item => new ObTaskContract
{
GeneratedPdfFileId = pdfFileId,
ContractStatus = TaskContractStatus.Archived
})
.Where(item => item.Id == document.TaskContractId)
.Where(item => item.Id == document.TaskContractId && process.IsCurrent == true)
.ExecuteCommandAsync();
}
@@ -1081,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;
}
@@ -1121,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();
}
@@ -1138,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;
@@ -1247,24 +1317,24 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
}
/// <summary>
/// 电子签管理沿用入职审核人权限,不新增另一套权限来源。
/// 电子签操作遵循任务创建人/任务管理全部权限。
/// </summary>
private async Task<ObOnboardingTask> GetAuthorizedTask(long taskId)
{
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
?? throw Oops.Bah("入职任务不存在");
var config = await _configService.GetByConfigKey(
CateGoryConst.CONFIG_BIZ_DEFINE,
"onboard.reviewer.userIds")
?? throw Oops.Bah("请配置入职审核人");
var reviewerIds = OnboardingReviewerConfigParser.Parse(
config.ConfigValue,
task.CompanyId);
if (!reviewerIds.Contains(UserManager.UserId))
throw Oops.Bah("您不是该入职任务的审核人");
var task = await GetTask(taskId);
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
return task;
}
private async Task<ObOnboardingTask> GetTask(long taskId)
{
return await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
?? throw Oops.Bah("入职任务不存在");
}
private async Task<ObElectronicSignProcess> GetLatestProcess(long taskId)
{
return await _db.Queryable<ObElectronicSignProcess>()
@@ -1333,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)
@@ -1490,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)
@@ -0,0 +1,23 @@
namespace SimpleAdmin.Application;
/// <summary>同步第三方状态时的单向迁移保护;未知/陈旧结果不能覆盖已确认的签署进度。</summary>
internal static class OnboardingSignStateGuard
{
internal static string Resolve(string current, string incoming)
{
if (current == ElectronicSignStatus.Archived && incoming == ElectronicSignStatus.Completed)
return ElectronicSignStatus.Archived;
if (IsTerminal(current) && current != incoming)
throw Oops.Bah("契约锁返回状态与本地终态不一致,已保留本地状态,请核实后重试");
if (current == ElectronicSignStatus.PendingCompanySeal
&& incoming is not (ElectronicSignStatus.PendingCompanySeal
or ElectronicSignStatus.Completed or ElectronicSignStatus.Archived
or ElectronicSignStatus.Rejected or ElectronicSignStatus.Invalid or ElectronicSignStatus.Expired))
throw Oops.Bah("契约锁状态暂未同步,已保留员工签署结果,请稍后重试");
return incoming;
}
private static bool IsTerminal(string status)
=> status is ElectronicSignStatus.Completed or ElectronicSignStatus.Archived
or ElectronicSignStatus.Rejected or ElectronicSignStatus.Invalid or ElectronicSignStatus.Expired;
}
@@ -40,84 +40,9 @@ public class QiyuesuoClient : IQiyuesuoClient
public async Task<QiyuesuoContractResult> 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<QiyuesuoContractResult>(
"/v2/contract/draft",
body);
QiyuesuoContractRequestBuilder.BuildDraft(input));
}
/// <inheritdoc />
@@ -151,6 +76,9 @@ public class QiyuesuoClient : IQiyuesuoClient
contractId = input.ContractId,
bizId = input.BizId,
tenantName = input.CompanyName,
// 入职合同的签署位置由系统统一预设,禁止签署人自行增加位置,
// 避免同一份材料出现“预设签名 + 手动新增签名”的重复签名。
stamperRule = "MUST_FORBID",
locateAllStamperKeywords = false,
stampers = input.Stampers
});
@@ -34,6 +34,9 @@ public class QiyuesuoCreateDraftInput
/// <summary>契约锁服务器回调地址。</summary>
public string CallbackUrl { get; set; }
/// <summary>是否需要企业签章节点;仅员工签字的流程不创建该节点。</summary>
public bool RequiresCompanySign { get; set; } = true;
}
/// <summary>
@@ -0,0 +1,206 @@
namespace SimpleAdmin.Application;
/// <summary>发起签约前按文件冻结的签字、盖章要求。</summary>
internal sealed record OnboardingSigningDocument(
string DocumentId,
string EmployeeSignKeyword,
string CompanySealKeyword,
bool? CompanySealEnabled,
bool? AcrossPageSealEnabled);
/// <summary>构造契约锁请求;无数据库和网络访问,员工签字始终覆盖全部文件1。</summary>
internal static class QiyuesuoContractRequestBuilder
{
internal static bool CompanySealEnabled(bool? enabled) => enabled ?? true;
internal static bool AcrossPageSealEnabled(bool? enabled, QiyuesuoSettingOptions setting) =>
enabled ?? setting.EnableAcrossPageSeal;
internal static bool RequiresCompanySign(
bool? companySealEnabled,
bool? acrossPageSealEnabled,
QiyuesuoSettingOptions setting) =>
CompanySealEnabled(companySealEnabled) || AcrossPageSealEnabled(acrossPageSealEnabled, setting);
internal static object BuildDraft(QiyuesuoCreateDraftInput input)
{
long sealId = 0;
if (input.RequiresCompanySign && !long.TryParse(input.SealId, out sealId))
throw Oops.Bah("契约锁 SealId 配置格式不正确");
var signatories = new List<object>
{
new
{
tenantType = "PERSONAL",
tenantName = input.EmployeeName,
receiver = new
{
name = input.EmployeeName,
contact = input.EmployeeMobile,
contactType = "MOBILE"
},
serialNo = 1,
signatoryNo = "employee",
actions = new object[]
{
new
{
type = "PERSONAL",
name = "员工本人签署",
serialNo = 0,
actionNo = "employee_sign"
}
}
}
};
if (input.RequiresCompanySign)
{
signatories.Add(new
{
tenantType = "COMPANY",
tenantName = input.CompanyName,
receiver = new
{
name = input.CreatorName,
contact = input.CreatorMobile,
contactType = "MOBILE"
},
serialNo = 2,
signatoryNo = "company",
actions = new object[]
{
new
{
type = "COMPANY",
name = "企业人工盖章",
serialNo = 0,
autoSign = false,
sealId = input.SealId,
corpSealIds = new[] { sealId },
actionNo = "company_seal"
}
}
});
}
return new
{
bizId = input.BizId,
subject = input.Subject,
tenantName = input.CompanyName,
description = "OnboardX 入职电子签",
send = false,
callbackUrl = string.IsNullOrWhiteSpace(input.CallbackUrl) ? null : input.CallbackUrl.Trim(),
category = new { id = input.CategoryId },
creator = new
{
name = input.CreatorName,
contact = input.CreatorMobile,
contactType = "MOBILE"
},
signatories
};
}
internal static List<QiyuesuoStamperInput> BuildStampers(
IReadOnlyList<OnboardingSigningDocument> documents,
string personalSignatoryId,
string companyActionId,
string sealId,
QiyuesuoSettingOptions setting)
{
if (documents.Count == 0)
throw Oops.Bah("当前电子签流程没有可签署的文件");
if (documents.Any(item => string.IsNullOrWhiteSpace(item.DocumentId)))
throw Oops.Bah("契约锁未返回签署文档 Id");
if (string.IsNullOrWhiteSpace(personalSignatoryId))
throw Oops.Bah("契约锁未返回员工签署方 Id");
if (documents.Any(item => RequiresCompanySign(item.CompanySealEnabled, item.AcrossPageSealEnabled, setting))
&& string.IsNullOrWhiteSpace(companyActionId))
throw Oops.Bah("契约锁草稿未返回企业盖章动作");
var stampers = new List<QiyuesuoStamperInput>();
foreach (var document in documents)
{
if (string.IsNullOrWhiteSpace(document.EmployeeSignKeyword))
throw Oops.Bah("请配置员工签名定位关键字");
stampers.Add(new QiyuesuoStamperInput
{
SignatoryId = personalSignatoryId,
Type = "PERSONAL",
DocumentId = document.DocumentId,
Keyword = document.EmployeeSignKeyword,
KeywordIndex = Math.Max(1, setting.EmployeeSignKeywordIndex)
});
if (CompanySealEnabled(document.CompanySealEnabled))
{
if (string.IsNullOrWhiteSpace(document.CompanySealKeyword))
throw Oops.Bah("需要公司盖章的文件未配置企业盖章定位关键字");
stampers.Add(new QiyuesuoStamperInput
{
ActionId = companyActionId,
Type = "COMPANY",
DocumentId = document.DocumentId,
SealId = sealId,
Keyword = document.CompanySealKeyword,
KeywordIndex = Math.Max(1, setting.CompanySealKeywordIndex),
OffsetX = setting.CompanySealOffsetX,
OffsetY = setting.CompanySealOffsetY
});
}
}
// 合并骑缝章也只能包含已开启该开关的文件,不能把只需个人签字的材料混入。
var acrossDocuments = documents
.Where(item => AcrossPageSealEnabled(item.AcrossPageSealEnabled, setting))
.Select(item => item.DocumentId)
.ToList();
if (acrossDocuments.Count == 0)
return stampers;
var acrossType = setting.AcrossPageSealType?.Trim().ToUpperInvariant();
if (acrossType is not ("ALL_DOCUMENT" or "SINGLE_DOCUMENT"))
throw Oops.Bah("契约锁骑缝章模式只能配置为 ALL_DOCUMENT 或 SINGLE_DOCUMENT");
if (acrossType == "ALL_DOCUMENT")
{
stampers.Add(new QiyuesuoStamperInput
{
ActionId = companyActionId,
Type = "ACROSS_PAGE",
DocumentId = acrossDocuments[0],
DocumentIds = string.Join(",", acrossDocuments),
SealId = sealId,
AcrossType = acrossType
});
}
else
{
foreach (var documentId in acrossDocuments)
{
stampers.Add(new QiyuesuoStamperInput
{
ActionId = companyActionId,
Type = "ACROSS_PAGE",
DocumentId = documentId,
SealId = sealId,
AcrossType = acrossType
});
}
}
return stampers;
}
internal static string ResolvePendingStatus(QiyuesuoContractResult contract)
{
var employeeSigned = contract.Signatories.Any(item =>
string.Equals(item.TenantType, "PERSONAL", StringComparison.OrdinalIgnoreCase)
&& string.Equals(item.Status, "SIGNED", StringComparison.OrdinalIgnoreCase));
var hasCompanyAction = contract.Signatories.Any(item =>
string.Equals(item.TenantType, "COMPANY", StringComparison.OrdinalIgnoreCase)
&& item.Actions.Any(action => string.Equals(action.Type, "COMPANY", StringComparison.OrdinalIgnoreCase)));
// 纯员工签字流程等待平台 COMPLETE 回执,不制造不存在的企业盖章阶段。
return employeeSigned && hasCompanyAction
? ElectronicSignStatus.PendingCompanySeal
: ElectronicSignStatus.PendingEmployeeSign;
}
}
@@ -46,6 +46,58 @@ public class OnboardingEmployeePageInput : BasePageInput
/// 社保状态。
/// </summary>
public string SocialInsuranceStatus { get; set; }
/// <summary>
/// 合同即将到期筛选(30天内到期或已到期)。
/// </summary>
public bool? ContractExpiringSoon { get; set; }
}
/// <summary>
/// 正式员工档案分页数据及当前查询条件下的维度统计。
/// </summary>
public class OnboardingEmployeePageOutput : SqlSugarPagedList<ObEmployee>
{
/// <summary>
/// 不受快捷维度筛选影响的员工维度统计。
/// </summary>
public OnboardingEmployeeStatisticsOutput Statistics { get; set; } = new();
}
/// <summary>
/// 正式员工档案维度统计。
/// </summary>
public class OnboardingEmployeeStatisticsOutput
{
/// <summary>
/// 在册总数。
/// </summary>
public int Total { get; set; }
/// <summary>
/// 正式员工数量。
/// </summary>
public int FullTime { get; set; }
/// <summary>
/// 兼职员工数量。
/// </summary>
public int PartTime { get; set; }
/// <summary>
/// 试用期待转正数量。
/// </summary>
public int Probation { get; set; }
/// <summary>
/// 社保已缴纳数量。
/// </summary>
public int SocialPurchased { get; set; }
/// <summary>
/// 合同即将到期数量(30天内到期或已到期)。
/// </summary>
public int ExpiringContract { get; set; }
}
/// <summary>
@@ -6,9 +6,9 @@ namespace SimpleAdmin.Application;
public interface IOnboardingEmployeeService : ITransient
{
/// <summary>
/// 分页查询审核通过后生成的正式员工档案。
/// 分页查询审核通过后生成的正式员工档案及维度统计。
/// </summary>
Task<SqlSugarPagedList<ObEmployee>> Page(OnboardingEmployeePageInput input);
Task<OnboardingEmployeePageOutput> Page(OnboardingEmployeePageInput input);
/// <summary>
/// 按当前列表筛选条件导出员工明细。
/// </summary>
@@ -10,26 +10,41 @@ namespace SimpleAdmin.Application;
/// </summary>
public class OnboardingEmployeeService : IOnboardingEmployeeService
{
private const string EmployeeExportTemplateFileName = "员工导出模版.xlsx";
private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingEmployeeService()
public OnboardingEmployeeService(IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_dataPermission = dataPermission;
}
/// <summary>
/// 分页查询审核通过后生成的员工档案。
/// 分页查询审核通过后生成的员工档案及维度统计。
/// </summary>
public async Task<SqlSugarPagedList<ObEmployee>> Page(OnboardingEmployeePageInput input)
public async Task<OnboardingEmployeePageOutput> Page(OnboardingEmployeePageInput input)
{
var result = await BuildEmployeeQuery(input)
var list = await BuildEmployeeQuery(input)
.OrderByDescending(item => item.CreateTime)
.ToPagedListAsync(input.PageNum, input.PageSize);
if (result.List != null && result.List.Any())
await FillEmployeeMetadata(result.List);
if (list.List != null && list.List.Any())
await FillEmployeeMetadata(list.List);
return result;
var stats = await CalculateStatistics(input);
return new OnboardingEmployeePageOutput
{
PageNum = list.PageNum,
PageSize = list.PageSize,
Total = list.Total,
Pages = list.Pages,
List = list.List,
HasPrevPages = list.HasPrevPages,
HasNextPages = list.HasNextPages,
Statistics = stats
};
}
/// <inheritdoc />
@@ -96,12 +111,17 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
};
}).ToList();
using var package = new ExcelPackage();
var templatePath = ResolveEmployeeExportTemplatePath();
using var package = new ExcelPackage(new FileInfo(templatePath));
package.Workbook.Properties.Title = "员工明细";
package.Workbook.Properties.Subject = "员工基本、人事状态及主要合同信息";
package.Workbook.Properties.Created = DateTime.Now;
WriteEmployeeSheet(package.Workbook.Worksheets.Add("员工明细"), employeeRows);
var worksheet = package.Workbook.Worksheets.FirstOrDefault(item =>
item.Dimension != null
&& string.Equals(item.Cells[1, 1].Text.Trim(), "姓名", StringComparison.OrdinalIgnoreCase))
?? throw Oops.Bah("员工导出模板未找到以“姓名”开头的表头行,请检查模板内容");
WriteEmployeeSheet(worksheet, employeeRows);
return new OnboardingEmployeeExportFile
{
@@ -147,6 +167,40 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
.OrderBy(item => item.CreateTime)
.OrderBy(item => item.Id)
.ToListAsync();
if (string.IsNullOrWhiteSpace(employee.EmployeeNo))
{
var snapshotEmployeeNo = contractSnapshots
.Select(item => item.EmployeeNo)
.FirstOrDefault(val => !string.IsNullOrWhiteSpace(val));
if (string.IsNullOrWhiteSpace(snapshotEmployeeNo))
{
snapshotEmployeeNo = contractSnapshots
.Select(item => ExtractEmployeeNoFromVariables(item.VariablesJson))
.FirstOrDefault(val => !string.IsNullOrWhiteSpace(val));
}
if (string.IsNullOrWhiteSpace(snapshotEmployeeNo) && contracts.Count > 0)
{
var contractIds = contracts.Select(c => c.Id).ToList();
snapshotEmployeeNo = await _db.Queryable<ObTaskContractVariable>()
.Where(item => contractIds.Contains(item.TaskContractId) && item.VariableCode == "hr.EmployeeNo" && item.VariableValue != null && item.VariableValue != "")
.OrderBy(item => item.SortCode)
.Select(item => item.VariableValue)
.FirstAsync();
}
if (!string.IsNullOrWhiteSpace(snapshotEmployeeNo))
{
employee.EmployeeNo = snapshotEmployeeNo;
await _db.Updateable<ObEmployee>()
.SetColumns(item => new ObEmployee { EmployeeNo = snapshotEmployeeNo })
.Where(item => item.Id == employee.Id && (item.EmployeeNo == null || item.EmployeeNo == ""))
.ExecuteCommandAsync();
}
}
var materials = await GetEmployeeMaterials(task?.Id);
var electronicSignProcess = task == null
? null
@@ -178,12 +232,18 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
/// <inheritdoc />
public async Task UpdateHrInfo(OnboardingEmployeeHrUpdateInput input)
{
await OnboardingConcurrency.RunEmployee(_db, input.Id, () => UpdateHrInfoCore(input));
}
private async Task UpdateHrInfoCore(OnboardingEmployeeHrUpdateInput input)
{
ValidateEmploymentType(input.EmploymentType);
ValidateSocialInsuranceStatus(input.SocialInsuranceStatus);
var employee = await _db.Queryable<ObEmployee>().InSingleAsync(input.Id)
?? throw Oops.Bah("员工档案不存在");
await EnsureEmployeeCanManage(employee);
var before = CreateHrState(employee);
var regularizationStatus = employee.RegularizationStatus;
var regularizationDate = employee.RegularizationDate;
@@ -260,12 +320,18 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
/// <inheritdoc />
public async Task Regularize(OnboardingEmployeeRegularizeInput input)
{
await OnboardingConcurrency.RunEmployee(_db, input.Id, () => RegularizeCore(input));
}
private async Task RegularizeCore(OnboardingEmployeeRegularizeInput input)
{
if (!input.RegularizationDate.HasValue)
throw Oops.Bah("请选择转正日期");
var employee = await _db.Queryable<ObEmployee>().InSingleAsync(input.Id)
?? throw Oops.Bah("员工档案不存在");
await EnsureEmployeeCanManage(employee);
if (employee.EmploymentType == EmployeeEmploymentType.PartTime)
throw Oops.Bah("兼职员工不适用转正流程,请先调整用工类型");
if (employee.RegularizationStatus == EmployeeRegularizationStatus.Regular)
@@ -279,6 +345,8 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
.Where(item => item > 0)
.Distinct()
.ToList();
if (fileIds.Count > 10)
throw Oops.Bah("转正材料文件总数不能超过 10 份");
var files = fileIds.Count == 0
? new List<SysFile>()
: await _db.Queryable<SysFile>()
@@ -354,12 +422,18 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
/// <inheritdoc />
public async Task RevokeRegularization(OnboardingEmployeeRegularizationRevokeInput input)
{
await OnboardingConcurrency.RunEmployee(_db, input.Id, () => RevokeRegularizationCore(input));
}
private async Task RevokeRegularizationCore(OnboardingEmployeeRegularizationRevokeInput input)
{
if (string.IsNullOrWhiteSpace(input.Remark))
throw Oops.Bah("请填写撤销原因");
var employee = await _db.Queryable<ObEmployee>().InSingleAsync(input.Id)
?? throw Oops.Bah("员工档案不存在");
await EnsureEmployeeCanManage(employee);
if (employee.RegularizationStatus != EmployeeRegularizationStatus.Regular)
throw Oops.Bah("该员工当前不是已转正状态");
@@ -411,6 +485,14 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
/// <summary>
/// 查询员工来源任务中上传的全部材料,并补充原始文件名、后缀和文件大小。
/// </summary>
private Task EnsureEmployeeCanManage(ObEmployee employee)
{
return _dataPermission.EnsureCanManage(
employee.CreateUserId,
OnboardingPermissionCodes.EmployeeManageAll,
"员工档案");
}
private async Task<List<OnboardingTaskMaterialDetailOutput>> GetEmployeeMaterials(long? taskId)
{
if (!taskId.HasValue)
@@ -620,21 +702,31 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
await FillMissingEntryDates(employeeList);
}
/// <summary>
/// 构造基础员工查询(包含组织、岗位和姓名/手机号搜索),供列表、统计和导出复用。
/// </summary>
private ISugarQueryable<ObEmployee> BuildBaseEmployeeQuery(OnboardingEmployeePageInput input)
{
return _db.Queryable<ObEmployee>()
.WhereIF(
!string.IsNullOrWhiteSpace(input?.EmployeeName),
item => item.EmployeeName.Contains(input.EmployeeName)
|| item.IdCardNumber.Contains(input.EmployeeName))
.WhereIF(!string.IsNullOrWhiteSpace(input?.Phone), item => item.Phone.Contains(input.Phone))
.WhereIF(input?.CompanyId.HasValue == true, item => item.CompanyId == input.CompanyId.Value)
.WhereIF(input?.DepartmentId.HasValue == true, item => item.DepartmentId == input.DepartmentId.Value)
.WhereIF(input?.PositionId.HasValue == true, item => item.PositionId == input.PositionId.Value);
}
/// <summary>
/// 构造列表和导出共用的员工筛选,保证页面看到的范围与导出范围一致。
/// </summary>
private ISugarQueryable<ObEmployee> BuildEmployeeQuery(OnboardingEmployeePageInput input)
{
input ??= new OnboardingEmployeePageInput();
return _db.Queryable<ObEmployee>()
.WhereIF(
!string.IsNullOrWhiteSpace(input.EmployeeName),
item => item.EmployeeName.Contains(input.EmployeeName)
|| item.IdCardNumber.Contains(input.EmployeeName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Phone), item => item.Phone.Contains(input.Phone))
.WhereIF(input.CompanyId.HasValue, item => item.CompanyId == input.CompanyId.Value)
.WhereIF(input.DepartmentId.HasValue, item => item.DepartmentId == input.DepartmentId.Value)
.WhereIF(input.PositionId.HasValue, item => item.PositionId == input.PositionId.Value)
var expiringThreshold = DateTime.Today.AddDays(30).Date.AddDays(1).AddSeconds(-1);
return BuildBaseEmployeeQuery(input)
.WhereIF(
!string.IsNullOrWhiteSpace(input.EmploymentType),
item => item.EmploymentType == input.EmploymentType)
@@ -643,7 +735,50 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
item => item.RegularizationStatus == input.RegularizationStatus)
.WhereIF(
!string.IsNullOrWhiteSpace(input.SocialInsuranceStatus),
item => item.SocialInsuranceStatus == input.SocialInsuranceStatus);
item => item.SocialInsuranceStatus == input.SocialInsuranceStatus)
.WhereIF(
input.ContractExpiringSoon == true,
item => item.ContractEndDate != null && item.ContractEndDate <= expiringThreshold);
}
/// <summary>
/// 计算员工档案的各项维度统计数量,受组织及关键字联动,但不受快捷状态筛选影响。
/// </summary>
private async Task<OnboardingEmployeeStatisticsOutput> CalculateStatistics(OnboardingEmployeePageInput input)
{
var expiringThreshold = DateTime.Today.AddDays(30).Date.AddDays(1).AddSeconds(-1);
var total = await BuildBaseEmployeeQuery(input).CountAsync();
if (total == 0)
{
return new OnboardingEmployeeStatisticsOutput();
}
var fullTime = await BuildBaseEmployeeQuery(input)
.Where(item => item.EmploymentType == EmployeeEmploymentType.FullTime)
.CountAsync();
var partTime = await BuildBaseEmployeeQuery(input)
.Where(item => item.EmploymentType == EmployeeEmploymentType.PartTime)
.CountAsync();
var probation = await BuildBaseEmployeeQuery(input)
.Where(item => item.RegularizationStatus == EmployeeRegularizationStatus.Probation)
.CountAsync();
var socialPurchased = await BuildBaseEmployeeQuery(input)
.Where(item => item.SocialInsuranceStatus == EmployeeSocialInsuranceStatus.Purchased)
.CountAsync();
var expiringContract = await BuildBaseEmployeeQuery(input)
.Where(item => item.ContractEndDate != null && item.ContractEndDate <= expiringThreshold)
.CountAsync();
return new OnboardingEmployeeStatisticsOutput
{
Total = total,
FullTime = fullTime,
PartTime = partTime,
Probation = probation,
SocialPurchased = socialPurchased,
ExpiringContract = expiringContract
};
}
private static void WriteEmployeeSheet(
@@ -697,7 +832,102 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
TextColumn("工资卡开户行", row => row.Employee.BankName, 22)
};
WriteWorksheet(worksheet, columns, rows);
WriteEmployeeTemplateWorksheet(worksheet, columns, rows);
}
private static void WriteEmployeeTemplateWorksheet(
ExcelWorksheet worksheet,
IReadOnlyList<ExportColumn<EmployeeExportContext>> columns,
IReadOnlyList<EmployeeExportContext> rows)
{
var templateColumnCount = worksheet.Dimension?.End.Column ?? 0;
if (templateColumnCount == 0)
throw Oops.Bah("员工导出模板没有配置表头");
var columnsByHeader = columns.ToDictionary(
item => item.Header,
item => item,
StringComparer.OrdinalIgnoreCase);
var headerAliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["户口所在地"] = "户籍所在地"
};
var templateColumns = new List<(int Index, ExportColumn<EmployeeExportContext> Column)>();
var mappedHeaders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var columnIndex = 1; columnIndex <= templateColumnCount; columnIndex++)
{
var templateHeader = worksheet.Cells[1, columnIndex].Text?.Trim();
if (string.IsNullOrWhiteSpace(templateHeader))
throw Oops.Bah($"员工导出模板第 {columnIndex} 列表头为空,请补充后重新导出");
var exportHeader = headerAliases.GetValueOrDefault(templateHeader, templateHeader);
if (!columnsByHeader.TryGetValue(exportHeader, out var exportColumn))
throw Oops.Bah($"员工导出模板包含无法识别的表头“{templateHeader}”,请检查模板");
if (!mappedHeaders.Add(exportHeader))
throw Oops.Bah($"员工导出模板表头“{templateHeader}”重复,请检查模板");
templateColumns.Add((columnIndex, exportColumn));
}
var missingHeaders = columns
.Where(item => !mappedHeaders.Contains(item.Header))
.Select(item => item.Header)
.ToList();
if (missingHeaders.Count > 0)
throw Oops.Bah($"员工导出模板缺少表头:{string.Join("、", missingHeaders)}");
var existingLastRow = worksheet.Dimension?.End.Row ?? 1;
for (var rowIndex = 2; rowIndex <= existingLastRow; rowIndex++)
{
for (var columnIndex = 1; columnIndex <= templateColumnCount; columnIndex++)
worksheet.Cells[rowIndex, columnIndex].Value = null;
}
for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
{
var excelRow = rowIndex + 2;
foreach (var templateColumn in templateColumns)
{
var cell = worksheet.Cells[excelRow, templateColumn.Index];
var value = templateColumn.Column.Value(rows[rowIndex]);
if (value != null)
cell.Value = value;
// 表头可能因复制粘贴带有日期等显示格式;数据格式应读取模板数据行,
// 未配置时再使用字段类型定义的默认格式,避免薪资数值显示成日期。
var templateNumberFormat = worksheet.Cells[2, templateColumn.Index]
.Style.Numberformat.Format;
cell.Style.Numberformat.Format = string.IsNullOrWhiteSpace(templateNumberFormat)
|| string.Equals(
templateNumberFormat,
"General",
StringComparison.OrdinalIgnoreCase)
? templateColumn.Column.NumberFormat
: templateNumberFormat;
}
}
}
private static string ResolveEmployeeExportTemplatePath()
{
var currentDirectory = Directory.GetCurrentDirectory();
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "Upload", EmployeeExportTemplateFileName),
Path.Combine(currentDirectory, "Upload", EmployeeExportTemplateFileName),
Path.Combine(currentDirectory, "SimpleAdmin.Web.Core", "Upload", EmployeeExportTemplateFileName),
Path.GetFullPath(Path.Combine(
currentDirectory,
"..",
"SimpleAdmin.Web.Core",
"Upload",
EmployeeExportTemplateFileName))
};
var templatePath = candidates.FirstOrDefault(File.Exists);
return templatePath
?? throw Oops.Bah("员工导出模板文件不存在,请检查部署目录中的 Upload/员工导出模版.xlsx");
}
private static void WriteContractSheet(
@@ -921,6 +1151,43 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
}
}
private static string ExtractEmployeeNoFromVariables(string variablesJson)
{
if (string.IsNullOrWhiteSpace(variablesJson))
return null;
try
{
using var doc = global::System.Text.Json.JsonDocument.Parse(variablesJson);
var root = doc.RootElement;
if (root.ValueKind == global::System.Text.Json.JsonValueKind.Object)
{
foreach (var prop in root.EnumerateObject())
{
if (string.Equals(prop.Name, "hr.EmployeeNo", StringComparison.OrdinalIgnoreCase))
{
if (prop.Value.ValueKind == global::System.Text.Json.JsonValueKind.Object
&& prop.Value.TryGetProperty("variableValue", out var valProp)
&& valProp.ValueKind == global::System.Text.Json.JsonValueKind.String)
{
return valProp.GetString();
}
if (prop.Value.ValueKind == global::System.Text.Json.JsonValueKind.String)
{
return prop.Value.GetString();
}
}
}
}
}
catch
{
// 忽略异常 JSON
}
return null;
}
private static DateTime? ParseDate(string value)
{
return DateTime.TryParse(value, out var date) ? date.Date : null;
@@ -6,10 +6,12 @@ namespace SimpleAdmin.Application;
public class OnboardingEntryConfigService : IOnboardingEntryConfigService
{
private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingEntryConfigService()
public OnboardingEntryConfigService(IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_dataPermission = dataPermission;
}
/// <summary>
@@ -55,10 +57,15 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
if (input.Id.HasValue)
{
var exists = await _db.Queryable<ObFormField>().AnyAsync(x => x.Id == input.Id.Value);
if (!exists)
throw Oops.Bah("字段不存在");
var exists = await _db.Queryable<ObFormField>().InSingleAsync(input.Id.Value)
?? throw Oops.Bah("字段不存在");
await _dataPermission.EnsureCanManage(
exists.CreateUserId,
OnboardingPermissionCodes.EntryConfigManageAll,
"入职字段");
entity.CreateUserId = exists.CreateUserId;
entity.CreateUser = exists.CreateUser;
entity.CreateTime = exists.CreateTime;
await _db.Updateable(entity).ExecuteCommandAsync();
return;
}
@@ -95,12 +102,21 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
if (input.Ids.Count == 0)
return;
var isReferenced = await _db.Queryable<ObFormRequirement>()
var fields = await _db.Queryable<ObFormField>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (fields.Count != input.Ids.Distinct().Count())
throw Oops.Bah("字段不存在");
await _dataPermission.EnsureCanManage(
fields.Select(item => item.CreateUserId),
OnboardingPermissionCodes.EntryConfigManageAll,
"入职字段");
var isReferencedByTask = await _db.Queryable<ObTaskFormRequirement>()
.AnyAsync(x => input.Ids.Contains(x.FieldId));
var isReferencedByTemplate = await _db.Queryable<ObOnboardingTemplateFormField>()
.AnyAsync(x => input.Ids.Contains(x.FieldId));
if (isReferenced || isReferencedByTemplate)
throw Oops.Bah("字段已被入职模板引用,不能删除");
if (isReferencedByTask)
throw Oops.Bah("字段已被入职任务引用,不能删除,可改为停用");
await _db.Deleteable<ObFormField>().In(input.Ids).ExecuteCommandAsync();
}
@@ -126,6 +142,24 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
throw Oops.Bah("资料名称不能为空");
if (input.MaxFileCount <= 0)
throw Oops.Bah("最大文件数量必须大于0");
if (input.MaxFileSizeMb.HasValue && (input.MaxFileSizeMb.Value <= 0 || input.MaxFileSizeMb.Value > 20))
throw Oops.Bah("单个文件大小必须设置在1至20MB之间");
var supportedExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"png", "jpg", "jpeg", "gif", "bmp", "webp", "pdf"
};
var allowedExtensions = (input.AllowedExtensions ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(item => item.TrimStart('.').ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (allowedExtensions.Count == 0)
allowedExtensions.AddRange(new[] { "png", "jpg", "jpeg", "pdf" });
var unsupportedExtensions = allowedExtensions.Where(item => !supportedExtensions.Contains(item)).ToList();
if (unsupportedExtensions.Count > 0)
throw Oops.Bah($"不支持的材料扩展名:{string.Join("、", unsupportedExtensions)},仅允许图片或PDF文件");
input.AllowedExtensions = string.Join(",", allowedExtensions);
var materialCodeExists = await _db.Queryable<ObMaterialType>()
.AnyAsync(x => x.MaterialCode == input.MaterialCode && x.Id != input.Id);
@@ -145,10 +179,15 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
if (input.Id.HasValue)
{
var exists = await _db.Queryable<ObMaterialType>().AnyAsync(x => x.Id == input.Id.Value);
if (!exists)
throw Oops.Bah("资料类型不存在");
var exists = await _db.Queryable<ObMaterialType>().InSingleAsync(input.Id.Value)
?? throw Oops.Bah("资料类型不存在");
await _dataPermission.EnsureCanManage(
exists.CreateUserId,
OnboardingPermissionCodes.EntryConfigManageAll,
"资料类型");
entity.CreateUserId = exists.CreateUserId;
entity.CreateUser = exists.CreateUser;
entity.CreateTime = exists.CreateTime;
await _db.Updateable(entity).ExecuteCommandAsync();
return;
}
@@ -164,13 +203,26 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
if (input.Ids.Count == 0)
return;
var isReferenced = await _db.Queryable<ObMaterialRequirement>()
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
var materialTypes = await _db.Queryable<ObMaterialType>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (materialTypes.Count != input.Ids.Distinct().Count())
throw Oops.Bah("资料类型不存在");
await _dataPermission.EnsureCanManage(
materialTypes.Select(item => item.CreateUserId),
OnboardingPermissionCodes.EntryConfigManageAll,
"资料类型");
var isReferencedByTemplate = await _db.Queryable<ObOnboardingTemplateMaterial>()
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
if (isReferenced || isReferencedByTemplate)
if (isReferencedByTemplate)
throw Oops.Bah("资料类型已被入职模板引用,不能删除");
var isReferencedByTask = await _db.Queryable<ObTaskMaterialRequirement>()
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
if (isReferencedByTask)
throw Oops.Bah("资料类型已被入职任务引用,不能删除,可改为停用");
var hasUploads = await _db.Queryable<ObTaskMaterial>()
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
if (hasUploads)
@@ -31,6 +31,14 @@ public class OnboardingIdentityValidationService : IOnboardingIdentityValidation
throw Oops.Bah("该姓名和身份证号已完成正式员工建档,请勿重复提交入职资料");
}
// 身份证号码代表唯一自然人。即使姓名填写不同,也不能绕过正式员工重复校验。
var formalEmployeeIdCardExists = await _db.Queryable<ObEmployee>()
.AnyAsync(item => item.IdCardNumber == idCardNumber);
if (formalEmployeeIdCardExists)
{
throw Oops.Bah("该身份证号码已完成正式员工建档,请核对姓名和身份证号码;如需更正,请联系 HR");
}
// 待填写任务不占用身份,以实际先提交的任务为准;撤销或过期任务允许重新发起。
var duplicateTaskExists = await _db.Queryable<ObTaskEmployeeInfo>()
.InnerJoin<ObOnboardingTask>((info, task) => info.TaskId == task.Id)
@@ -42,9 +50,24 @@ public class OnboardingIdentityValidationService : IOnboardingIdentityValidation
|| task.TaskStatus == OnboardingTaskStatus.Approved
|| task.TaskStatus == OnboardingTaskStatus.Archived))
.AnyAsync();
if (!duplicateTaskExists)
return;
if (duplicateTaskExists)
{
throw Oops.Bah("该姓名和身份证号已存在正在办理的入职任务,请勿重复提交;如需补充材料,请联系 HR 继续使用原任务链接");
}
// 再单独按身份证号码校验,防止不同姓名使用同一证件重复提交不同任务。
var duplicateIdCardTaskExists = await _db.Queryable<ObTaskEmployeeInfo>()
.InnerJoin<ObOnboardingTask>((info, task) => info.TaskId == task.Id)
.Where((info, task) => info.TaskId != currentTaskId
&& info.IdCardNumber == idCardNumber
&& (task.TaskStatus == OnboardingTaskStatus.Reviewing
|| task.TaskStatus == OnboardingTaskStatus.Rejected
|| task.TaskStatus == OnboardingTaskStatus.Approved
|| task.TaskStatus == OnboardingTaskStatus.Archived))
.AnyAsync();
if (duplicateIdCardTaskExists)
{
throw Oops.Bah("该身份证号码已存在正在办理的入职任务,请核对姓名和身份证号码;如需补充材料,请联系 HR 继续使用原任务链接");
}
}
}
@@ -6,18 +6,23 @@ namespace SimpleAdmin.Application;
public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
{
private readonly SqlSugarScope _db;
private readonly IConfigService _config;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTaskLifecycleService(IConfigService config)
public OnboardingTaskLifecycleService(IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_config = config;
_dataPermission = dataPermission;
}
/// <summary>
/// 归档已审核通过的入职任务。
/// </summary>
public async Task Archive(OnboardingAuditActionInput input)
{
await OnboardingConcurrency.RunTask(_db, input.TaskId, () => ArchiveCore(input));
}
private async Task ArchiveCore(OnboardingAuditActionInput input)
{
var task = await Authorize(input.TaskId);
if (task.TaskStatus != OnboardingTaskStatus.Approved)
@@ -57,6 +62,11 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
/// 撤销入职任务并同步撤销仍然有效的员工填写链接。
/// </summary>
public async Task Revoke(OnboardingAuditActionInput input)
{
await OnboardingConcurrency.RunTask(_db, input.TaskId, () => RevokeCore(input));
}
private async Task RevokeCore(OnboardingAuditActionInput input)
{
var task = await Authorize(input.TaskId);
if (task.TaskStatus == OnboardingTaskStatus.Approved
@@ -102,6 +112,11 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
/// 删除已撤销的入职任务及其全部关联快照数据。
/// </summary>
public async Task Delete(BaseIdInput input)
{
await OnboardingConcurrency.RunTask(_db, input.Id, () => DeleteCore(input));
}
private async Task DeleteCore(BaseIdInput input)
{
var task = await Authorize(input.Id);
if (task.TaskStatus != OnboardingTaskStatus.Revoked)
@@ -117,6 +132,28 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
.Select(x => x.Id)
.ToListAsync();
var signProcessIds = await _db.Queryable<ObElectronicSignProcess>()
.Where(x => x.TaskId == task.Id)
.Select(x => x.Id)
.ToListAsync();
if (signProcessIds.Count > 0)
{
await _db.Deleteable<ObElectronicSignEvent>()
.Where(x => x.SignProcessId.HasValue
&& signProcessIds.Contains(x.SignProcessId.Value))
.ExecuteCommandAsync();
await _db.Deleteable<ObElectronicSignDocument>()
.Where(x => signProcessIds.Contains(x.SignProcessId))
.ExecuteCommandAsync();
await _db.Deleteable<ObElectronicSignProcess>()
.Where(x => signProcessIds.Contains(x.Id))
.ExecuteCommandAsync();
}
await _db.Deleteable<ObEmployeeContractSnapshot>()
.Where(x => x.TaskId == task.Id)
.ExecuteCommandAsync();
if (taskContractIds.Count > 0)
{
await _db.Deleteable<ObTaskContractVariable>()
@@ -130,7 +167,6 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
await _db.Deleteable<ObTaskMaterial>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskEmployeeInfo>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskFormRequirement>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskFormValue>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskOperationLog>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskAudit>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObOnboardingTask>().In(task.Id).ExecuteCommandAsync();
@@ -147,14 +183,10 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
{
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
?? throw Oops.Bah("入职任务不存在");
var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds")
?? throw Oops.Bah("请配置入职审核人");
var reviewerIds = OnboardingReviewerConfigParser.Parse(config.ConfigValue, task.CompanyId);
if (!reviewerIds.Contains(UserManager.UserId))
{
throw Oops.Bah("您不是该入职任务的审核人");
}
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
return task;
}
@@ -6,18 +6,24 @@ namespace SimpleAdmin.Application;
public class OnboardingMaterialReviewService : IOnboardingMaterialReviewService
{
private readonly SqlSugarScope _db;
private readonly IConfigService _config;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingMaterialReviewService(IConfigService config)
public OnboardingMaterialReviewService(
IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_config = config;
_dataPermission = dataPermission;
}
/// <summary>
/// 审核单份候选人上传材料并记录审核意见。
/// </summary>
public async Task Review(OnboardingMaterialReviewInput input)
{
await OnboardingConcurrency.RunMaterial(_db, input.TaskMaterialId, () => ReviewCore(input));
}
private async Task ReviewCore(OnboardingMaterialReviewInput input)
{
if (input.ReviewStatus != TaskMaterialReviewStatus.Passed
&& input.ReviewStatus != TaskMaterialReviewStatus.NeedSupplement)
@@ -46,15 +52,11 @@ public class OnboardingMaterialReviewService : IOnboardingMaterialReviewService
await _db.Updateable(material).ExecuteCommandAsync();
}
private async Task EnsureReviewer(ObOnboardingTask task)
private Task EnsureReviewer(ObOnboardingTask task)
{
var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds")
?? throw Oops.Bah("请配置入职审核人");
var reviewerIds = OnboardingReviewerConfigParser.Parse(config.ConfigValue, task.CompanyId);
if (!reviewerIds.Contains(UserManager.UserId))
{
throw Oops.Bah("您不是该入职任务的审核人");
}
return _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
}
}
@@ -6,10 +6,12 @@ namespace SimpleAdmin.Application;
public class OnboardingTemplateService : IOnboardingTemplateService
{
private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTemplateService()
public OnboardingTemplateService(IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_dataPermission = dataPermission;
}
/// <inheritdoc />
@@ -59,6 +61,8 @@ public class OnboardingTemplateService : IOnboardingTemplateService
return new OnboardingTemplateDetailOutput
{
Id = summary.Id,
CreateUserId = summary.CreateUserId,
CreateUser = summary.CreateUser,
TemplateCode = summary.TemplateCode,
TemplateName = summary.TemplateName,
CompanyId = summary.CompanyId,
@@ -92,10 +96,16 @@ public class OnboardingTemplateService : IOnboardingTemplateService
NormalizeAndValidateInput(input);
await ValidateReferences(input);
var codeOwner = await _db.Queryable<ObOnboardingTemplate>()
var codeOwnerQuery = _db.Queryable<ObOnboardingTemplate>()
.ClearFilter()
.FirstAsync(item => item.TemplateCode == input.TemplateCode
&& (!input.Id.HasValue || item.Id != input.Id.Value));
.Where(item => item.TemplateCode == input.TemplateCode);
if (input.Id.HasValue)
{
var currentTemplateId = input.Id.Value;
codeOwnerQuery = codeOwnerQuery.Where(item => item.Id != currentTemplateId);
}
var codeOwner = await codeOwnerQuery.FirstAsync();
if (codeOwner != null && !codeOwner.IsDelete)
throw Oops.Bah("入职模板编码已存在");
@@ -108,6 +118,19 @@ public class OnboardingTemplateService : IOnboardingTemplateService
? codeOwner.Id
: input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
if (input.Id.HasValue || isRestore)
{
var existing = isRestore
? codeOwner
: await _db.Queryable<ObOnboardingTemplate>().InSingleAsync(templateId);
if (existing == null)
throw Oops.Bah("入职模板不存在");
await _dataPermission.EnsureCanManage(
existing.CreateUserId,
OnboardingPermissionCodes.EntryConfigManageAll,
"入职模板");
}
var entity = new ObOnboardingTemplate
{
Id = templateId,
@@ -154,6 +177,16 @@ public class OnboardingTemplateService : IOnboardingTemplateService
if (input.Ids.Count == 0)
return;
var templates = await _db.Queryable<ObOnboardingTemplate>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (templates.Count != input.Ids.Distinct().Count())
throw Oops.Bah("入职模板不存在");
await _dataPermission.EnsureCanManage(
templates.Select(item => item.CreateUserId),
OnboardingPermissionCodes.EntryConfigManageAll,
"入职模板");
var isUsed = await _db.Queryable<ObOnboardingTask>()
.AnyAsync(item => input.Ids.Contains(item.OnboardingTemplateId));
if (isUsed)
@@ -196,7 +229,65 @@ public class OnboardingTemplateService : IOnboardingTemplateService
&& templateIds.Contains(item.Id))
.OrderBy(item => item.TemplateName)
.ToListAsync();
if (templates.Count == 0)
return await BuildOutputs(templates);
var onboardingTemplateIds = templates.Select(item => item.Id).ToList();
var contractConfigs = await _db.Queryable<ObOnboardingTemplateContract>()
.Where(item => onboardingTemplateIds.Contains(item.OnboardingTemplateId))
.ToListAsync();
if (contractConfigs.Count == 0)
return await BuildOutputs(new List<ObOnboardingTemplate>());
var contractTemplateIds = contractConfigs
.Select(item => item.ContractTemplateId)
.Distinct()
.ToList();
var contractVersionIds = contractConfigs
.Select(item => item.ContractTemplateVersionId)
.Distinct()
.ToList();
var contractTemplates = await _db.Queryable<ObContractTemplate>()
.Where(item => contractTemplateIds.Contains(item.Id))
.ToListAsync();
var contractVersions = await _db.Queryable<ObContractTemplateVersion>()
.Where(item => contractVersionIds.Contains(item.Id))
.ToListAsync();
var contractPositions = await _db.Queryable<ObContractTemplatePosition>()
.Where(item => contractTemplateIds.Contains(item.ContractTemplateId)
&& item.PositionId == positionId)
.ToListAsync();
var now = DateTime.Now;
var availableTemplates = templates.Where(template =>
{
var configs = contractConfigs
.Where(item => item.OnboardingTemplateId == template.Id)
.ToList();
if (configs.Count == 0)
return false;
return configs.All(config =>
{
var contractTemplate = contractTemplates
.FirstOrDefault(item => item.Id == config.ContractTemplateId);
var version = contractVersions
.FirstOrDefault(item => item.Id == config.ContractTemplateVersionId);
return contractTemplate != null
&& string.Equals(
contractTemplate.Status,
"enable",
StringComparison.OrdinalIgnoreCase)
&& contractTemplate.CompanyId == template.CompanyId
&& contractTemplate.DepartmentId == template.DepartmentId
&& contractPositions.Any(item =>
item.ContractTemplateId == config.ContractTemplateId)
&& version != null
&& version.TemplateId == config.ContractTemplateId
&& ContractTemplateVersionValidity.IsAvailable(version, now);
});
}).ToList();
return await BuildOutputs(availableTemplates);
}
private static void NormalizeAndValidateInput(OnboardingTemplateSaveInput input)
@@ -278,6 +369,9 @@ public class OnboardingTemplateService : IOnboardingTemplateService
{
throw Oops.Bah("合同模板与版本不匹配");
}
var now = DateTime.Now;
if (versions.Any(version => !ContractTemplateVersionValidity.IsAvailable(version, now)))
throw Oops.Bah("入职模板只能选择当前已生效且尚未失效的启用签署材料版本");
var contractTemplateIds = input.Contracts
.Select(item => item.ContractTemplateId)
@@ -319,8 +413,6 @@ public class OnboardingTemplateService : IOnboardingTemplateService
"enable",
global::System.StringComparison.OrdinalIgnoreCase)))
throw Oops.Bah("启用入职模板前,请先启用全部合同模板");
if (versions.Any(item => item.VersionStatus != ContractTemplateVersionStatus.Enabled))
throw Oops.Bah("启用入职模板前,请先启用全部合同版本");
}
var materialIds = input.Materials.Select(item => item.MaterialTypeId).ToList();
@@ -422,6 +514,8 @@ public class OnboardingTemplateService : IOnboardingTemplateService
return new OnboardingTemplateOutput
{
Id = template.Id,
CreateUserId = template.CreateUserId,
CreateUser = template.CreateUser,
TemplateCode = template.TemplateCode,
TemplateName = template.TemplateName,
CompanyId = template.CompanyId,
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务岗位分页查询参数。
@@ -52,13 +52,21 @@ public class OnboardingPositionAddInput : ObPosition
/// 岗位业务编码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "岗位编码不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "岗位编码不能超过50个字符")]
public override string PositionCode { get; set; }
/// <summary>
/// 岗位名称。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "岗位名称不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "岗位名称不能超过50个字符")]
public override string PositionName { get; set; }
/// <summary>
/// 业务备注。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(200, ErrorMessage = "备注不能超过200个字符")]
public override string Remark { get; set; }
}
/// <summary>
@@ -5,6 +5,13 @@ namespace SimpleAdmin.Application;
/// </summary>
public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPositionService
{
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingPositionService(IOnboardingDataPermissionService dataPermission)
{
_dataPermission = dataPermission;
}
/// <summary>
/// 分页查询入职业务岗位。
/// </summary>
@@ -16,7 +23,7 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
.WhereIF(!string.IsNullOrWhiteSpace(input.PositionCode), it => it.PositionCode.Contains(input.PositionCode))
.WhereIF(!string.IsNullOrWhiteSpace(input.PositionName), it => it.PositionName.Contains(input.PositionName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentId", "PositionCode", "PositionName"))
.OrderByDescending(it => it.CreateTime)
.OrderBy(it => it.PositionName)
.ToPagedListAsync(input.PageNum, input.PageSize);
@@ -107,6 +114,10 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
throw Oops.Bah("请选择所属部门");
var position = await Detail(new BaseIdInput { Id = input.Id });
await _dataPermission.EnsureCanManage(
position.CreateUserId,
OnboardingPermissionCodes.SetupManageAll,
"岗位");
await EnsureScopeCanChange(position, input.CompanyId, input.DepartmentId.Value);
await EnsureCompanyExists(input.CompanyId);
await EnsureDepartmentExists(input.CompanyId, input.DepartmentId.Value);
@@ -122,6 +133,16 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
if (input.Ids.Count == 0)
return;
var positions = await Context.Queryable<ObPosition>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (positions.Count != input.Ids.Distinct().Count())
throw Oops.Bah("岗位不存在");
await _dataPermission.EnsureCanManage(
positions.Select(item => item.CreateUserId),
OnboardingPermissionCodes.SetupManageAll,
"岗位");
var hasTasks = await Context.Queryable<ObOnboardingTask>()
.AnyAsync(it => input.Ids.Contains(it.PositionId));
if (hasTasks)
@@ -71,4 +71,27 @@ public class OnboardingSubmitInput
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string Token { get; set; }
/// <summary>
/// 员工是否已在最终提交前逐项确认实名认证信息。
/// </summary>
public bool IdentityInformationConfirmed { get; set; }
/// <summary>
/// 员工最终确认的真实姓名。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string ConfirmedEmployeeName { get; set; }
/// <summary>
/// 员工最终确认的身份证号码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string ConfirmedIdCardNumber { get; set; }
/// <summary>
/// 员工最终确认的手机号码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string ConfirmedPhone { get; set; }
}
@@ -5,6 +5,7 @@ namespace SimpleAdmin.Application;
/// </summary>
public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionService
{
private const int PublicMaterialMaxFileSizeMb = 20;
private readonly SqlSugarScope _db;
private readonly IOnboardingContractSnapshotService _snapshotService;
private readonly IOnboardingIdentityValidationService _identityValidationService;
@@ -25,6 +26,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 关联已上传 OSS 文件与任务材料要求。
/// </summary>
public async Task SaveMaterial(OnboardingSaveMaterialInput input)
{
await OnboardingConcurrency.RunPublic(_db, input.Token, () => SaveMaterialCore(input), transactional: true);
}
private async Task SaveMaterialCore(OnboardingSaveMaterialInput input)
{
var task = await GetEditableTask(input.Token);
var requirement = await _db.Queryable<ObTaskMaterialRequirement>()
@@ -84,6 +90,12 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// </summary>
public async Task DeleteMaterial(
OnboardingDeleteMaterialInput input)
{
await OnboardingConcurrency.RunPublic(_db, input.Token, () => DeleteMaterialCore(input), transactional: true);
}
private async Task DeleteMaterialCore(
OnboardingDeleteMaterialInput input)
{
var task = await GetEditableTask(input.Token);
var material = await _db.Queryable<ObTaskMaterial>()
@@ -102,6 +114,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 记录候选人已经查看并确认动态生成的合同。
/// </summary>
public async Task ConfirmContract(OnboardingConfirmContractInput input)
{
await OnboardingConcurrency.RunPublic(_db, input.Token, () => ConfirmContractCore(input), transactional: true);
}
private async Task ConfirmContractCore(OnboardingConfirmContractInput input)
{
var task = await GetEditableTask(input.Token);
var contract = await _db.Queryable<ObTaskContract>()
@@ -119,6 +136,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。
/// </summary>
public async Task Submit(OnboardingSubmitInput input)
{
await OnboardingConcurrency.RunPublic(_db, input.Token, () => SubmitCore(input), transactional: true, protectIdentity: true);
}
private async Task SubmitCore(OnboardingSubmitInput input)
{
var task = await GetEditableTask(input.Token);
var info = await _db.Queryable<ObTaskEmployeeInfo>()
@@ -126,7 +148,10 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
?? throw Oops.Bah("请先完整填写基本信息");
// 最终提交必须重新执行全量校验,不能只依赖员工手机端的页面校验。
OnboardingEmployeeInfoValidator.ValidateAndNormalize(info);
var formRequirements =
await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, task.Id);
OnboardingEmployeeInfoValidator.ValidateAndNormalize(info, formRequirements);
EnsureIdentityInformationConfirmed(input, info);
await _db.Updateable(info).ExecuteCommandAsync();
await _identityValidationService.EnsureNoDuplicate(
@@ -155,11 +180,21 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
if (hasUnconfirmedRequiredContract)
throw Oops.Bah("请查看并确认全部必签合同");
var submittedAt = DateTime.Now;
var identityConfirmationHash = BuildIdentityConfirmationHash(info);
var originalStatus = task.TaskStatus;
var originalSubmitCount = task.SubmitCount;
task.TaskStatus = OnboardingTaskStatus.Reviewing;
task.SubmitCount++;
task.LastSubmittedAt = DateTime.Now;
task.LastSubmittedAt = submittedAt;
task.RejectReason = null;
await _db.Updateable(task).ExecuteCommandAsync();
var changed = await _db.Updateable(task)
.UpdateColumns(item => new { item.TaskStatus, item.SubmitCount, item.LastSubmittedAt, item.RejectReason })
.Where(item => item.Id == task.Id && item.TaskStatus == originalStatus
&& item.SubmitCount == originalSubmitCount)
.ExecuteCommandAsync();
if (changed != 1)
throw Oops.Bah("任务状态已变更,请刷新后重试");
await _db.Insertable(new ObTaskAudit
{
@@ -167,9 +202,18 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
TaskId = task.Id,
AuditRound = task.SubmitCount,
AuditAction = OnboardingAuditAction.Submit,
AuditOpinion = "员工提交入职资料",
SnapshotJson = global::System.Text.Json.JsonSerializer.Serialize(new { info, materials }),
AuditedAt = DateTime.Now
AuditOpinion = "员工提交入职资料,并确认姓名、身份证号码和手机号码用于实名认证",
SnapshotJson = global::System.Text.Json.JsonSerializer.Serialize(new
{
info,
materials,
identityConfirmation = new
{
confirmedAt = submittedAt,
summaryHash = identityConfirmationHash
}
}),
AuditedAt = submittedAt
}).ExecuteCommandAsync();
}
@@ -190,10 +234,12 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
if (suffixNotAllowed)
throw Oops.Bah("该材料不允许上传此文件类型");
var fileTooLarge = requirement.MaxFileSizeMb.HasValue
&& input.FileLength > requirement.MaxFileSizeMb.Value * 1024L * 1024L;
var maxFileSizeMb = requirement.MaxFileSizeMb.HasValue
? Math.Min(requirement.MaxFileSizeMb.Value, PublicMaterialMaxFileSizeMb)
: PublicMaterialMaxFileSizeMb;
var fileTooLarge = input.FileLength > maxFileSizeMb * 1024L * 1024L;
if (fileTooLarge)
throw Oops.Bah("文件超过允许的大小");
throw Oops.Bah($"单个文件不能超过{maxFileSizeMb}MB,请压缩后重新上传");
var materials = await _db.Queryable<ObTaskMaterial>()
.Where(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId)
@@ -214,6 +260,38 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
throw Oops.Bah("该材料已达到最大上传数量");
}
/// <summary>
/// 后端再次比较员工勾选确认时的三项实名信息,防止绕过页面或确认后数据发生变化。
/// </summary>
private static void EnsureIdentityInformationConfirmed(
OnboardingSubmitInput input,
ObTaskEmployeeInfo info)
{
if (!input.IdentityInformationConfirmed)
throw Oops.Bah("请逐项确认姓名、身份证号码和手机号码无误");
var confirmedEmployeeName = input.ConfirmedEmployeeName?.Trim();
var confirmedIdCardNumber = input.ConfirmedIdCardNumber?.Trim().ToUpperInvariant();
var confirmedPhone = input.ConfirmedPhone?.Trim();
if (!string.Equals(confirmedEmployeeName, info.EmployeeName, StringComparison.Ordinal))
throw Oops.Bah("姓名在确认后发生了变化,请返回基本信息重新核对并确认");
if (!string.Equals(confirmedIdCardNumber, info.IdCardNumber, StringComparison.Ordinal))
throw Oops.Bah("身份证号码在确认后发生了变化,请返回基本信息重新核对并确认");
if (!string.Equals(confirmedPhone, info.Phone, StringComparison.Ordinal))
throw Oops.Bah("手机号码在确认后发生了变化,请返回基本信息重新核对并确认");
}
/// <summary>
/// 审核快照只记录实名信息摘要,完整原始值仍以本轮员工信息快照为准。
/// </summary>
private static string BuildIdentityConfirmationHash(ObTaskEmployeeInfo info)
{
var value = $"{info.EmployeeName}\n{info.IdCardNumber}\n{info.Phone}";
var bytes = global::System.Text.Encoding.UTF8.GetBytes(value);
return Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData(bytes));
}
private Task<List<ObTaskMaterialRequirement>> GetMaterialRequirements(long taskId)
{
return _db.Queryable<ObTaskMaterialRequirement>()
@@ -0,0 +1,68 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 生成并读取入职登记表内置员工字段的任务级必填快照。
/// </summary>
internal static class OnboardingEmployeeFieldRequirementSnapshot
{
private const string EmployeeVariablePrefix = "employee.";
/// <summary>
/// 根据当前内置员工变量配置创建一份任务快照。
/// </summary>
public static async Task<List<ObTaskFormRequirement>> BuildFromCurrentDefinitions(
SqlSugarScope db,
long taskId)
{
var definitions = await db.Queryable<ObVariableDefinition>()
.Where(item => item.IsBuiltIn
&& item.VariableCategory == ContractVariableType.Employee)
.OrderBy(item => item.SortCode)
.OrderBy(item => item.Id)
.ToListAsync();
return definitions.Select(definition => new ObTaskFormRequirement
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
FieldId = definition.Id,
FieldCode = definition.VariableCode,
FieldName = definition.VariableName,
DataType = definition.DataType,
Required = definition.Required,
SortCode = definition.SortCode
}).ToList();
}
/// <summary>
/// 读取任务冻结配置;历史任务没有员工字段快照时使用当前配置兼容。
/// </summary>
public static async Task<List<ObTaskFormRequirement>> GetForTask(
SqlSugarScope db,
long taskId)
{
var snapshots = await db.Queryable<ObTaskFormRequirement>()
.Where(item => item.TaskId == taskId)
.OrderBy(item => item.SortCode)
.ToListAsync();
if (snapshots.Any(IsEmployeeField))
return snapshots;
var compatibilitySnapshots = await BuildFromCurrentDefinitions(db, taskId);
if (snapshots.Count == 0)
return compatibilitySnapshots;
snapshots.AddRange(compatibilitySnapshots);
return snapshots.OrderBy(item => item.SortCode).ToList();
}
/// <summary>
/// 判断快照是否对应入职登记表内置员工字段。
/// </summary>
public static bool IsEmployeeField(ObTaskFormRequirement requirement)
{
return requirement?.FieldCode?.StartsWith(
EmployeeVariablePrefix,
StringComparison.OrdinalIgnoreCase) == true;
}
}
@@ -16,9 +16,11 @@ internal static class OnboardingEmployeeInfoValidator
};
/// <summary>
/// 规范化文本,并校验固定登记表字段的格式。
/// 规范化文本,并按照任务冻结配置校验固定登记表必填项及字段格式。
/// </summary>
public static void ValidateAndNormalize(ObTaskEmployeeInfo input)
public static void ValidateAndNormalize(
ObTaskEmployeeInfo input,
IReadOnlyCollection<ObTaskFormRequirement> fieldRequirements = null)
{
input.EmployeeName = input.EmployeeName?.Trim();
input.Phone = input.Phone?.Trim();
@@ -26,20 +28,27 @@ internal static class OnboardingEmployeeInfoValidator
input.IdCardNumber = input.IdCardNumber?.Trim().ToUpperInvariant();
input.BankCardNumber = global::System.Text.RegularExpressions.Regex.Replace(
input.BankCardNumber ?? string.Empty,
"[\\s-]",
@"[\s-]",
string.Empty);
input.BankName = input.BankName?.Trim();
input.BankAccountName = input.EmployeeName;
ValidateRequiredFields(input, fieldRequirements);
// 电子签约实名认证始终依赖以下三项,不能通过后台配置关闭。
if (string.IsNullOrWhiteSpace(input.EmployeeName) || input.EmployeeName.Length > 100)
throw Oops.Bah("员工姓名不能为空且不能超过 100 个字符");
if (input.Gender != "男" && input.Gender != "女")
if (!string.IsNullOrWhiteSpace(input.Gender)
&& input.Gender != "男"
&& input.Gender != "女")
{
throw Oops.Bah("请选择正确的性别");
}
if (!global::System.Text.RegularExpressions.Regex.IsMatch(
input.Phone ?? string.Empty,
"^1[3-9]\\d{9}$"))
@"^1[3-9]\d{9}$"))
{
throw Oops.Bah("请输入正确的 11 位手机号码");
}
@@ -51,10 +60,11 @@ internal static class OnboardingEmployeeInfoValidator
ValidateOptionalEmail(input.Email);
ValidateOptionalNumber(input.Height, "身高", 100M, 250M, "厘米");
ValidateOptionalNumber(input.Weight, "体重", 30M, 300M, "公斤");
if (!string.IsNullOrWhiteSpace(input.BankCardNumber))
ValidateBankCard(input.BankCardNumber);
if (string.IsNullOrWhiteSpace(input.BankName) || input.BankName.Length > 100)
throw Oops.Bah("开户银行名称不能为空且不能超过 100 个字符");
if (!string.IsNullOrWhiteSpace(input.BankName) && input.BankName.Length > 100)
throw Oops.Bah("开户银行名称不能超过 100 个字符");
ValidateOptionalContactPhone(input.EmergencyPhone);
ValidateEducationHistory(input.EducationHistoryJson);
@@ -63,6 +73,40 @@ internal static class OnboardingEmployeeInfoValidator
ValidateJson(input.CustomVariablesJson, global::System.Text.Json.JsonValueKind.Object, "员工补充变量");
}
/// <summary>
/// 按任务快照校验后台配置为必填的内置员工字段。
/// </summary>
private static void ValidateRequiredFields(
ObTaskEmployeeInfo input,
IReadOnlyCollection<ObTaskFormRequirement> fieldRequirements)
{
if (fieldRequirements == null || fieldRequirements.Count == 0)
return;
foreach (var requirement in fieldRequirements.Where(item =>
item.Required
&& OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField(item)))
{
var separatorIndex = requirement.FieldCode.LastIndexOf('.');
var propertyName = separatorIndex >= 0
? requirement.FieldCode[(separatorIndex + 1)..]
: requirement.FieldCode;
var property = typeof(ObTaskEmployeeInfo).GetProperty(
propertyName,
global::System.Reflection.BindingFlags.Instance
| global::System.Reflection.BindingFlags.Public
| global::System.Reflection.BindingFlags.IgnoreCase);
if (property == null)
{
throw Oops.Bah(
$"内置员工变量“{requirement.FieldCode}”没有对应的入职登记表字段,请联系管理员");
}
var value = property.GetValue(input)?.ToString();
if (string.IsNullOrWhiteSpace(value))
throw Oops.Bah($"请填写入职登记表“{requirement.FieldName}”");
}
}
private static bool TryValidateIdCard(
string idCardNumber,
out DateTime birthday,
@@ -106,7 +150,7 @@ internal static class OnboardingEmployeeInfoValidator
return false;
}
private static void ValidateBirthday(string birthdayText, DateTime idCardBirthday)
private static void ValidateBirthday(string birthdayText, DateTime? idCardBirthday)
{
if (string.IsNullOrWhiteSpace(birthdayText))
return;
@@ -120,7 +164,7 @@ internal static class OnboardingEmployeeInfoValidator
if (!birthdayIsValid || birthday.Date > DateTime.Today)
throw Oops.Bah("请输入正确的出生日期");
if (birthday.Date != idCardBirthday.Date)
if (idCardBirthday.HasValue && birthday.Date != idCardBirthday.Value.Date)
throw Oops.Bah("出生日期与身份证号码不一致");
}
@@ -185,7 +229,7 @@ internal static class OnboardingEmployeeInfoValidator
GetRequiredRowValue(row, "location", rowNumber, "教育经历", "学校或机构");
GetRequiredRowValue(row, "content", rowNumber, "教育经历", "专业或学习内容");
GetRequiredRowValue(row, "result", rowNumber, "教育经历", "结果或学位");
});
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一条教育学习经历");
}
private static void ValidateWorkHistory(string json)
@@ -196,7 +240,7 @@ internal static class OnboardingEmployeeInfoValidator
GetRequiredRowValue(row, "companyName", rowNumber, "工作经历", "工作单位");
GetRequiredRowValue(row, "position", rowNumber, "工作经历", "担任职务");
GetRequiredRowValue(row, "leaveReason", rowNumber, "工作经历", "离职原因");
});
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一条工作经历(应届生可填写兼职、在校或实习经历)");
}
private static void ValidateFamilyMembers(string json)
@@ -217,16 +261,22 @@ internal static class OnboardingEmployeeInfoValidator
"^(1[3-9]\\d{9}|0\\d{2,3}-?\\d{7,8})$");
if (!phoneIsValid)
throw Oops.Bah($"第 {rowNumber} 条家庭成员的联系电话格式不正确");
});
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一位家庭成员信息");
}
private static void ValidateHistoryRows(
string json,
string fieldName,
Action<global::System.Text.Json.JsonElement, int> validateRow)
Action<global::System.Text.Json.JsonElement, int> validateRow,
bool requireAtLeastOne = false,
string emptyErrorMessage = null)
{
if (string.IsNullOrWhiteSpace(json))
{
if (requireAtLeastOne)
throw Oops.Bah(emptyErrorMessage ?? $"请至少添加一条{fieldName}");
return;
}
try
{
@@ -243,6 +293,11 @@ internal static class OnboardingEmployeeInfoValidator
validateRow(row, rowNumber);
}
if (requireAtLeastOne && rowNumber == 0)
{
throw Oops.Bah(emptyErrorMessage ?? $"请至少添加一条{fieldName}");
}
}
catch (global::System.Text.Json.JsonException)
{
@@ -25,6 +25,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 通过有效 Token 获取任务创建时冻结的材料、合同和新版入职登记表信息。
/// </summary>
public async Task<OnboardingTaskPublicDetailOutput> Detail(OnboardingTaskPublicAccessInput input)
{
return await OnboardingConcurrency.RunPublic(_db, input.Token, () => DetailCore(input));
}
private async Task<OnboardingTaskPublicDetailOutput> DetailCore(OnboardingTaskPublicAccessInput input)
{
var link = await GetActiveLink(input.Token);
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId)
@@ -40,6 +45,8 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
.Where(item => item.TaskId == task.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
var formRequirements =
await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, task.Id);
var contracts = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == task.Id)
@@ -106,7 +113,17 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
Contracts = contracts,
DynamicValues = new List<ObTaskFormValue>(),
Materials = materialOutputs,
FormFields = new List<OnboardingPublicFormFieldOutput>(),
FormFields = formRequirements
.Where(OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField)
.Select(requirement => new OnboardingPublicFormFieldOutput
{
FieldId = requirement.FieldId,
FieldCode = requirement.FieldCode,
FieldName = requirement.FieldName,
DataType = requirement.DataType,
OptionsJson = requirement.OptionsJson,
Required = requirement.Required
}).ToList(),
MaterialRequirements = materialRequirements.Select(requirement => new OnboardingPublicMaterialOutput
{
MaterialTypeId = requirement.MaterialTypeId,
@@ -124,6 +141,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 保存候选人的固定基础信息并刷新尚未确认的合同变量快照。
/// </summary>
public async Task SaveEmployeeInfo(OnboardingTaskPublicSaveInput input)
{
await OnboardingConcurrency.RunPublic(_db, input.Token, () => SaveEmployeeInfoCore(input), transactional: true);
}
private async Task SaveEmployeeInfoCore(OnboardingTaskPublicSaveInput input)
{
var link = await GetActiveLink(input.Token);
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId)
@@ -134,7 +156,9 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
throw Oops.Bah("当前任务不可编辑");
}
OnboardingEmployeeInfoValidator.ValidateAndNormalize(input);
var formRequirements =
await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, task.Id);
OnboardingEmployeeInfoValidator.ValidateAndNormalize(input, formRequirements);
await _identityValidationService.EnsureNoDuplicate(
task.Id,
input.EmployeeName,
@@ -178,6 +202,12 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// </summary>
public async Task<OnboardingPublicContractPrepareOutput> PrepareContract(
OnboardingPublicContractSourceInput input)
{
return await OnboardingConcurrency.RunPublic(_db, input.Token, () => PrepareContractCore(input));
}
private async Task<OnboardingPublicContractPrepareOutput> PrepareContractCore(
OnboardingPublicContractSourceInput input)
{
var link = await GetActiveLink(input.Token);
var contract = await _db.Queryable<ObTaskContract>()
@@ -200,10 +230,9 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
}
await _snapshotService.Build(link.TaskId);
var fileId = await _contractDocumentService.Generate(new OnboardingGenerateContractInput
{
TaskContractId = contract.Id
});
var fileId = await _contractDocumentService.GenerateForPublicAccess(
contract.Id,
link.TaskId);
var generatedAt = await _db.Queryable<ObTaskContract>()
.Where(item => item.Id == contract.Id)
@@ -220,6 +249,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 返回已经准备完成的合同文件,下载阶段不再执行变量校验和文档生成。
/// </summary>
public async Task<long> GetContractSourceFileId(OnboardingPublicContractSourceInput input)
{
return await OnboardingConcurrency.RunPublic(_db, input.Token, () => GetContractSourceFileIdCore(input));
}
private async Task<long> GetContractSourceFileIdCore(OnboardingPublicContractSourceInput input)
{
var link = await GetActiveLink(input.Token);
var contract = await _db.Queryable<ObTaskContract>()
@@ -8,14 +8,17 @@ public class OnboardingTaskService : IOnboardingTaskService
private readonly SqlSugarScope _db;
private readonly IConfigService _configService;
private readonly IOnboardingContractSnapshotService _snapshotService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTaskService(
IConfigService configService,
IOnboardingContractSnapshotService snapshotService)
IOnboardingContractSnapshotService snapshotService,
IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_configService = configService;
_snapshotService = snapshotService;
_dataPermission = dataPermission;
}
/// <summary>
@@ -92,11 +95,13 @@ public class OnboardingTaskService : IOnboardingTaskService
throw Oops.Bah("入职模板包含不适用于当前岗位的合同模板");
var versionIds = contractConfigs.Select(item => item.ContractTemplateVersionId).Distinct().ToList();
var enabledVersionCount = await _db.Queryable<ObContractTemplateVersion>()
.CountAsync(item => versionIds.Contains(item.Id)
&& item.VersionStatus == ContractTemplateVersionStatus.Enabled);
if (enabledVersionCount != versionIds.Count)
throw Oops.Bah("入职模板包含未启用的合同版本,请先调整模板配置");
var now = DateTime.Now;
var taskVersions = await _db.Queryable<ObContractTemplateVersion>()
.Where(item => versionIds.Contains(item.Id))
.ToListAsync();
if (taskVersions.Count != versionIds.Count
|| taskVersions.Any(item => !ContractTemplateVersionValidity.IsAvailable(item, now)))
throw Oops.Bah("入职模板包含未生效、已失效或未启用的签署材料版本,请先调整模板配置");
var materialIds = materialConfigs.Select(item => item.MaterialTypeId).Distinct().ToList();
var materialTypes = materialIds.Count == 0
@@ -158,9 +163,15 @@ public class OnboardingTaskService : IOnboardingTaskService
CreateTime = DateTime.Now
}).ExecuteCommandAsync();
var employeeFieldRequirements =
await OnboardingEmployeeFieldRequirementSnapshot.BuildFromCurrentDefinitions(_db, taskId);
if (employeeFieldRequirements.Count > 0)
await _db.Insertable(employeeFieldRequirements).ExecuteCommandAsync();
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(),
@@ -170,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();
@@ -237,6 +250,10 @@ public class OnboardingTaskService : IOnboardingTaskService
var sourceTask = await _db.Queryable<ObOnboardingTask>()
.InSingleAsync(input.SourceTaskId)
?? throw Oops.Bah("原入职任务不存在");
await _dataPermission.EnsureCanManage(
sourceTask.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
if (!sourceTask.DepartmentId.HasValue)
throw Oops.Bah("原入职任务未设置部门,不能复制");
@@ -278,16 +295,22 @@ public class OnboardingTaskService : IOnboardingTaskService
.Select(item => item.TemplateVersionId)
.Distinct()
.ToList();
var enabledVersionCount = await _db.Queryable<ObContractTemplateVersion>()
.CountAsync(item => versionIds.Contains(item.Id)
&& item.VersionStatus == ContractTemplateVersionStatus.Enabled);
if (enabledVersionCount != versionIds.Count)
throw Oops.Bah("原任务包含已停用的合同版本,不能继续复制");
var now = DateTime.Now;
var copyVersions = await _db.Queryable<ObContractTemplateVersion>()
.Where(item => versionIds.Contains(item.Id))
.ToListAsync();
if (copyVersions.Count != versionIds.Count
|| copyVersions.Any(item => !ContractTemplateVersionValidity.IsAvailable(item, now)))
throw Oops.Bah("原任务包含未生效、已失效或未启用的签署材料版本,不能继续复制");
var sourceMaterialRequirements = await _db.Queryable<ObTaskMaterialRequirement>()
.Where(item => item.TaskId == sourceTask.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
var sourceEmployeeFieldRequirements =
(await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, sourceTask.Id))
.Where(OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField)
.ToList();
var sourceContractIds = sourceContracts.Select(item => item.Id).ToList();
var sourceManualVariables = await _db.Queryable<ObTaskContractVariable>()
.Where(item => sourceContractIds.Contains(item.TaskContractId)
@@ -327,6 +350,7 @@ public class OnboardingTaskService : IOnboardingTaskService
sourceTask,
sourceContracts,
sourceMaterialRequirements,
sourceEmployeeFieldRequirements,
initialContractVariables,
expiryHours,
item.Remark.Trim());
@@ -354,6 +378,7 @@ public class OnboardingTaskService : IOnboardingTaskService
ObOnboardingTask sourceTask,
List<ObTaskContract> sourceContracts,
List<ObTaskMaterialRequirement> sourceMaterialRequirements,
List<ObTaskFormRequirement> sourceEmployeeFieldRequirements,
List<OnboardingInitialContractVariableInput> initialContractVariables,
int expiryHours,
string remark)
@@ -398,6 +423,24 @@ public class OnboardingTaskService : IOnboardingTaskService
CreateTime = now
}).ExecuteCommandAsync();
if (sourceEmployeeFieldRequirements.Count > 0)
{
var copiedEmployeeFieldRequirements = sourceEmployeeFieldRequirements.Select(source =>
new ObTaskFormRequirement
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
FieldId = source.FieldId,
FieldCode = source.FieldCode,
FieldName = source.FieldName,
DataType = source.DataType,
OptionsJson = source.OptionsJson,
Required = source.Required,
SortCode = source.SortCode
}).ToList();
await _db.Insertable(copiedEmployeeFieldRequirements).ExecuteCommandAsync();
}
var copiedContracts = sourceContracts.Select(source => new ObTaskContract
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
@@ -407,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();
@@ -6,9 +6,13 @@ namespace SimpleAdmin.Application;
public class OnboardingTaskContractVariableService : IOnboardingTaskContractVariableService
{
private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTaskContractVariableService()
=> _db = DbContext.DB;
public OnboardingTaskContractVariableService(IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_dataPermission = dataPermission;
}
/// <summary>
/// 查询任务下全部合同及其变量当前值。
@@ -77,6 +81,11 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
/// 保存允许人事编辑的任务合同变量值。
/// </summary>
public async Task Save(OnboardingTaskContractVariableSaveInput input)
{
await OnboardingConcurrency.RunContract(_db, input.TaskContractId, () => SaveCore(input), transactional: true);
}
private async Task SaveCore(OnboardingTaskContractVariableSaveInput input)
{
var contract = await _db.Queryable<ObTaskContract>()
.InSingleAsync(input.TaskContractId)
@@ -85,6 +94,10 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
var task = await _db.Queryable<ObOnboardingTask>()
.InSingleAsync(contract.TaskId)
?? throw Oops.Bah("入职任务不存在");
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
if (task.TaskStatus != OnboardingTaskStatus.PendingFill &&
task.TaskStatus != OnboardingTaskStatus.Rejected)
@@ -7,11 +7,15 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
{
private readonly SqlSugarScope _db;
private readonly IConfigService _configService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTaskQueryService(IConfigService configService)
public OnboardingTaskQueryService(
IConfigService configService,
IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_configService = configService;
_dataPermission = dataPermission;
}
/// <summary>
@@ -153,9 +157,18 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
/// 获取或重新生成任务有效填写链接。
/// </summary>
public async Task<OnboardingTaskCreatedOutput> GetTaskLink(BaseIdInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.Id, () => GetTaskLinkCore(input));
}
private async Task<OnboardingTaskCreatedOutput> GetTaskLinkCore(BaseIdInput input)
{
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(input.Id)
?? throw Oops.Bah("入职任务不存在");
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
var canShareLink = task.TaskStatus == OnboardingTaskStatus.PendingFill
|| task.TaskStatus == OnboardingTaskStatus.Rejected;
if (!canShareLink)
@@ -6,13 +6,15 @@
public class OnboardingVariableDefinitionService : IOnboardingVariableDefinitionService
{
private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
/// <summary>
/// 初始化合同变量字典服务。
/// </summary>
public OnboardingVariableDefinitionService()
public OnboardingVariableDefinitionService(IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_dataPermission = dataPermission;
}
/// <inheritdoc />
@@ -62,6 +64,10 @@ public class OnboardingVariableDefinitionService : IOnboardingVariableDefinition
var entity = await _db.Queryable<ObVariableDefinition>().InSingleAsync(input.Id)
?? throw Oops.Bah("变量不存在");
await _dataPermission.EnsureCanManage(
entity.CreateUserId,
OnboardingPermissionCodes.EntryConfigManageAll,
"合同变量");
if (!string.Equals(entity.VariableCode, input.VariableCode, StringComparison.Ordinal))
throw Oops.Bah("变量编码创建后不能修改");
@@ -86,6 +92,16 @@ public class OnboardingVariableDefinitionService : IOnboardingVariableDefinition
if (input.Ids.Count == 0)
return;
var variables = await _db.Queryable<ObVariableDefinition>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (variables.Count != input.Ids.Distinct().Count())
throw Oops.Bah("变量不存在");
await _dataPermission.EnsureCanManage(
variables.Select(item => item.CreateUserId),
OnboardingPermissionCodes.EntryConfigManageAll,
"合同变量");
var hasBuiltIn = await _db.Queryable<ObVariableDefinition>()
.Where(it => input.Ids.Contains(it.Id) && it.IsBuiltIn)
.AnyAsync();
+30 -19
View File
@@ -1,4 +1,4 @@
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
//
// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
// 1.请不要删除和修改根目录下的LICENSE文件。
@@ -45,26 +45,37 @@ public class BasePageInput : IValidatableObject
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
//配合小诺排序参数
if (SortOrder == "descend")
SortOrder = (string.IsNullOrWhiteSpace(SortOrder) ? "desc" : SortOrder.Trim().ToLowerInvariant()) switch
{
SortOrder = "desc";
"ascend" => "asc",
"descend" => "desc",
var value => value
};
if (SortOrder != "asc" && SortOrder != "desc")
yield return new ValidationResult("排序方向仅支持 asc/desc", new[] { nameof(SortOrder) });
if (!string.IsNullOrEmpty(SortField)
&& !global::System.Text.RegularExpressions.Regex.IsMatch(
SortField, @"\A[A-Za-z_][A-Za-z0-9_]{0,63}\z"))
yield return new ValidationResult("排序字段格式不正确", new[] { nameof(SortField) });
}
else if (SortOrder == "ascend")
/// <summary>
/// 将客户端字段映射为服务端白名单列,禁止把排序表达式传入 ORM。
/// 服务层再次校验,避免非 HTTP 调用绕过 MVC 参数校验。
/// </summary>
public string GetSafeOrderBy(params string[] allowedFields)
{
SortOrder = "asc";
}
if (!string.IsNullOrEmpty(SortField))
{
//分割排序字段
var fields = SortField.Split(" ");
if (fields.Length > 1)
{
yield return new ValidationResult("排序字段错误", new[]
{
nameof(SortField)
});
}
}
var error = Validate(new ValidationContext(this)).FirstOrDefault();
if (error != null)
throw Oops.Bah(error.ErrorMessage);
if (string.IsNullOrEmpty(SortField))
return string.Empty;
var field = allowedFields.FirstOrDefault(item =>
string.Equals(item, SortField, StringComparison.OrdinalIgnoreCase));
if (field == null)
throw Oops.Bah("不支持按此字段排序");
return $"{field} {SortOrder}";
}
}
@@ -66,6 +66,11 @@ public class LoginOutPut
/// </summary>
public class LoginUserOutput
{
/// <summary>
/// 用户Id
/// </summary>
public long Id { get; set; }
/// <summary>
/// 头像
///</summary>
@@ -34,7 +34,7 @@ public class FileService : DbRepository<SysFile>, IFileService
{
var query = Context.Queryable<SysFile>().WhereIF(!string.IsNullOrEmpty(input.Engine), it => it.Engine == input.Engine)//根据关键字查询
.WhereIF(!string.IsNullOrEmpty(input.SearchKey), it => it.Name.Contains(input.SearchKey))//根据关键字查询
.OrderByIF(!string.IsNullOrEmpty(input.SortField), $"{input.SortField} {input.SortOrder}")//排序
.OrderByIF(!string.IsNullOrEmpty(input.SortField), input.GetSafeOrderBy("Id", "Name", "Engine", "Bucket", "Suffix", "SizeKb", "SizeInfo", "CreateTime", "UpdateTime", "CreateUser"))//排序
.OrderBy(it => it.Id);
var pageInfo = await query.ToPagedListAsync(input.PageNum, input.PageSize);//分页
return pageInfo;
@@ -25,6 +25,10 @@ public class ConfigService : DbRepository<SysConfig>, IConfigService
/// <inheritdoc/>
public async Task<List<SysConfig>> GetConfigsByCategory(string category)
{
// 业务自定义配置需立即生效,始终以数据库中的最新数据为准。
if (category == CateGoryConst.CONFIG_BIZ_DEFINE)
return await GetListAsync(it => it.Category == category);
var key = SystemConst.CACHE_DEV_CONFIG + category;//系统配置key
//先从redis拿配置
var configList = _simpleCacheService.HashGetAll<SysConfig>(key).Values.ToList();
@@ -51,6 +55,10 @@ public class ConfigService : DbRepository<SysConfig>, IConfigService
/// <inheritdoc/>
public async Task<SysConfig> GetByConfigKey(string category, string configKey)
{
// 业务自定义配置不走缓存,避免修改后仍读取到本机旧值。
if (category == CateGoryConst.CONFIG_BIZ_DEFINE)
return await GetFirstAsync(it => it.Category == category && it.ConfigKey == configKey);
var key = SystemConst.CACHE_DEV_CONFIG + category;//系统配置key
//先从redis拿配置
var config = _simpleCacheService.HashGetOne<SysConfig>(key, configKey);
@@ -37,6 +37,11 @@ public class OnboardingContractTemplateController
if (!string.Equals(suffix, ".docx", StringComparison.OrdinalIgnoreCase))
throw Oops.Bah("合同模板仅支持 DOCX 文件");
await using var input = file.OpenReadStream();
using var bytes = new MemoryStream();
await input.CopyToAsync(bytes);
DocxTemplateProcessor.ExtractVariableCodes(bytes.ToArray());
return await _fileService.UploadFile(SysDictConst.FILE_ENGINE_ALIYUN_OSS, file);
}
@@ -130,6 +135,16 @@ public class OnboardingContractTemplateController
await _service.SetVersionStatus(input);
}
/// <summary>
/// 修改合同版本的公司盖章及骑缝章开关。
/// </summary>
[HttpPost("version/set-seals")]
[DisplayName("设置合同版本盖章配置")]
public async Task SetVersionSeals([FromBody] OnboardingContractVersionSealInput input)
{
await _service.SetVersionSeals(input);
}
/// <summary>
/// 删除未被入职模板或任务使用的合同模板版本。
/// </summary>
@@ -50,7 +50,7 @@ public class OnboardingPublicSubmissionController
/// </summary>
[HttpPost("upload-material")]
[Consumes("multipart/form-data")]
[RequestSizeLimit(20 * 1024 * 1024)]
[RequestSizeLimit(21 * 1024 * 1024)]
public async Task UploadMaterial(
[FromQuery] string token,
[FromQuery] long materialTypeId,
@@ -70,6 +70,7 @@ public class OnboardingPublicSubmissionController
FileLength = file.Length,
TaskMaterialId = taskMaterialId
});
await ValidateMaterialFileContent(file);
var fileId = await _fileService.UploadFile(SysDictConst.FILE_ENGINE_ALIYUN_OSS, file);
await _service.SaveMaterial(new OnboardingSaveMaterialInput
@@ -80,4 +81,66 @@ public class OnboardingPublicSubmissionController
TaskMaterialId = taskMaterialId
});
}
private static async Task ValidateMaterialFileContent(IFormFile file)
{
var suffix = global::System.IO.Path.GetExtension(file.FileName).TrimStart('.').ToLowerInvariant();
var supported = suffix is "png" or "jpg" or "jpeg" or "gif" or "bmp" or "webp" or "pdf";
if (!supported)
throw Oops.Bah("仅允许上传图片或PDF文件");
var header = new byte[12];
var bytesRead = 0;
await using (var stream = file.OpenReadStream())
{
while (bytesRead < header.Length)
{
var read = await stream.ReadAsync(header.AsMemory(bytesRead, header.Length - bytesRead));
if (read == 0) break;
bytesRead += read;
}
}
var contentMatchesSuffix = suffix switch
{
"jpg" or "jpeg" => bytesRead >= 3 && header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF,
"png" => bytesRead >= 8
&& header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47
&& header[4] == 0x0D && header[5] == 0x0A && header[6] == 0x1A && header[7] == 0x0A,
"gif" => bytesRead >= 6
&& header[0] == 0x47 && header[1] == 0x49 && header[2] == 0x46
&& header[3] == 0x38 && (header[4] == 0x37 || header[4] == 0x39) && header[5] == 0x61,
"bmp" => bytesRead >= 2 && header[0] == 0x42 && header[1] == 0x4D,
"webp" => bytesRead >= 12
&& header[0] == 0x52 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x46
&& header[8] == 0x57 && header[9] == 0x45 && header[10] == 0x42 && header[11] == 0x50,
"pdf" => bytesRead >= 5
&& header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44
&& header[3] == 0x46 && header[4] == 0x2D,
_ => false
};
if (!contentMatchesSuffix)
throw Oops.Bah("文件真实格式与扩展名不一致,请上传有效的图片或PDF文件");
if (suffix == "pdf") return;
await using var imageStream = file.OpenReadStream();
SkiaSharp.SKCodec codec;
try
{
codec = SkiaSharp.SKCodec.Create(imageStream);
}
catch
{
throw Oops.Bah("图片文件已损坏或格式不受支持");
}
using (codec)
{
if (codec == null || codec.Info.Width <= 0 || codec.Info.Height <= 0)
throw Oops.Bah("图片文件已损坏或格式不受支持");
if ((long)codec.Info.Width * codec.Info.Height > 40_000_000L)
throw Oops.Bah("图片分辨率过大,请压缩后重新上传");
}
}
}
Binary file not shown.
@@ -8,6 +8,11 @@
<None Update="ip2region.db">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<Content Include="..\SimpleAdmin.Web.Core\Upload\员工导出模版.xlsx">
<Link>Upload\员工导出模版.xlsx</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SimpleAdmin.Web.Core\SimpleAdmin.Web.Core.csproj" />
+2 -2
View File
@@ -1,6 +1,6 @@
{
"AppSettings": {
"InjectSpecificationDocument": true, //是否开启swagger
"InjectSpecificationDocument": false, //是否开启swagger
"Urls": "http://0.0.0.0:5566" //监听地址
},
"AllowedHosts": "*",
@@ -18,7 +18,7 @@
//swagger设置
"SpecificationDocumentSettings": {
"DocumentTitle": "未病局后台管理系统",
"DocumentTitle": "员工后台管理系统",
"DocExpansionState": "None",
"GroupOpenApiInfos": []
},
@@ -0,0 +1,28 @@
-- 入职登记表内置员工字段必填规则的任务快照表。
-- 可在本地、测试和正式环境重复执行。
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `ob_task_form_requirement` (
`Id` BIGINT NOT NULL COMMENT '主键',
`TaskId` BIGINT NOT NULL COMMENT '入职任务Id',
`FieldId` BIGINT NOT NULL COMMENT '原字段或变量定义Id',
`FieldCode` VARCHAR(100) NOT NULL COMMENT '冻结字段编码',
`FieldName` VARCHAR(200) NOT NULL COMMENT '冻结字段名称',
`DataType` VARCHAR(50) NOT NULL COMMENT '冻结数据类型',
`OptionsJson` JSON NULL COMMENT '冻结选项JSON',
`Required` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必填',
`SortCode` INT NULL COMMENT '显示顺序',
`Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '状态',
`CreateTime` DATETIME(3) NULL COMMENT '创建时间',
`UpdateTime` DATETIME(3) NULL COMMENT '更新时间',
`CreateUserId` BIGINT NULL COMMENT '创建者Id',
`UpdateUserId` BIGINT NULL COMMENT '修改者Id',
`CreateUser` VARCHAR(200) NULL COMMENT '创建人',
`UpdateUser` VARCHAR(200) NULL COMMENT '更新人',
`IsDelete` TINYINT(1) NULL DEFAULT 0 COMMENT '软删除',
`ExtJson` LONGTEXT NULL COMMENT '扩展信息',
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_ob_task_form_requirement` (`TaskId`, `FieldId`),
KEY `idx_ob_task_form_requirement_task_sort` (`TaskId`, `SortCode`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务入职登记字段必填快照';
@@ -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;
+243
View File
@@ -0,0 +1,243 @@
using System.Collections.Concurrent;
using SimpleAdmin.Core;
using SimpleAdmin.Application;
using SqlSugar;
var passed = 0;
void Check(bool condition, string name) {
if (!condition) throw new Exception("FAIL: " + name);
passed++;
Console.WriteLine("PASS: " + name);
}
void Reject(Action action, string name) {
try { action(); } catch (InvalidOperationException) { Check(true, name); return; }
throw new Exception("FAIL: expected rejection: " + name);
}
// Uses the real BasePageInput and real ORM SQL generation; never opens a DB connection.
using var db = new SqlSugarClient(new ConnectionConfig {
ConnectionString = "Server=127.0.0.1;Database=never_connected;Uid=unused;Pwd=unused;",
DbType = SqlSugar.DbType.MySql, IsAutoCloseConnection = true
});
foreach (var order in new[] { "asc, CASE WHEN 1=1 THEN 0 ELSE 1 END", "desc;DROP TABLE x", "asc--", "asc\tLIMIT 1", "random()" })
Reject(() => new BasePageInput { SortField = "Id", SortOrder = order }.GetSafeOrderBy("Id"), "reject sort direction: " + order);
foreach (var field in new[] { "Id,(CASE\tWHEN\t1=1\tTHEN\t0\tELSE\t1\tEND)", "Id\tDESC", "Id/**/", "Id;SELECT", "Id\n", "u.Id", "`Id`", "Id,Name", new string('a', 65) })
Reject(() => new BasePageInput { SortField = field }.GetSafeOrderBy("Id"), "reject sort field: " + field.Replace("\n", "<newline>"));
Reject(() => new BasePageInput { SortField = "NotAllowed" }.GetSafeOrderBy("Id"), "column whitelist enforced");
foreach (var sample in new[] { ("ascend", "asc"), ("descend", "desc"), (" ASC ", "asc"), ("", "desc"), ((string)null, "desc") }) {
var input = new BasePageInput { SortField = "id", SortOrder = sample.Item1 };
var orderBy = input.GetSafeOrderBy("Id", "Name");
Check(orderBy == "Id " + sample.Item2, "normalizes direction and maps column: " + sample.Item1);
var sql = db.Queryable<TestRow>().OrderBy(orderBy).ToSql().Key;
Check(sql.Contains("ORDER BY", StringComparison.OrdinalIgnoreCase) && !sql.Contains("CASE"), "safe ORM SQL generated");
}
Check(new BasePageInput().GetSafeOrderBy("Id") == "", "no requested sort preserves default");
// Uses the real state guard and production status constants.
Check(OnboardingSignStateGuard.Resolve(ElectronicSignStatus.PendingEmployeeSign, ElectronicSignStatus.PendingCompanySeal)
== ElectronicSignStatus.PendingCompanySeal, "employee signed -> company seal");
Check(OnboardingSignStateGuard.Resolve(ElectronicSignStatus.PendingCompanySeal, ElectronicSignStatus.Completed)
== ElectronicSignStatus.Completed, "company seal -> completed");
Check(OnboardingSignStateGuard.Resolve(ElectronicSignStatus.Archived, ElectronicSignStatus.Completed)
== ElectronicSignStatus.Archived, "completed provider result preserves local archive");
foreach (var terminal in new[] { ElectronicSignStatus.Completed, ElectronicSignStatus.Archived, ElectronicSignStatus.Invalid, ElectronicSignStatus.Expired, ElectronicSignStatus.Rejected }) {
Check(OnboardingSignStateGuard.Resolve(terminal, terminal) == terminal, "terminal replay allowed: " + terminal);
Reject(() => OnboardingSignStateGuard.Resolve(terminal, ElectronicSignStatus.PendingEmployeeSign), "terminal rollback rejected: " + terminal);
}
foreach (var backward in new[] { ElectronicSignStatus.PendingEmployeeSign, ElectronicSignStatus.Draft, ElectronicSignStatus.Creating, ElectronicSignStatus.Failed })
Reject(() => OnboardingSignStateGuard.Resolve(ElectronicSignStatus.PendingCompanySeal, backward), "signed progress cannot regress: " + backward);
// Uses the real signing-material validity rule. End time is exclusive.
var validityNow = new DateTime(2026, 9, 17, 12, 0, 0);
var availableVersion = new ObContractTemplateVersion { VersionStatus = ContractTemplateVersionStatus.Enabled };
Check(ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "enabled version without time bounds is available");
availableVersion.EffectiveDate = validityNow;
availableVersion.ExpiryDate = validityNow.AddSeconds(1);
Check(ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "effective boundary is inclusive");
availableVersion.ExpiryDate = validityNow;
Check(!ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "expiry boundary is exclusive");
availableVersion.EffectiveDate = validityNow.AddSeconds(1);
availableVersion.ExpiryDate = validityNow.AddMinutes(1);
Check(!ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "future version is unavailable");
availableVersion.EffectiveDate = validityNow.AddMinutes(-1);
availableVersion.ExpiryDate = validityNow.AddMinutes(1);
availableVersion.VersionStatus = ContractTemplateVersionStatus.Disabled;
Check(!ContractTemplateVersionValidity.IsAvailable(availableVersion, validityNow), "disabled version is unavailable");
ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow.AddSeconds(1));
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<int> Gate(string key, Func<Task<int>> action) => OnboardingOperationGate.Run(key, () => leases.Acquire(key), action);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var first = Gate("task:1", async () => { entered.SetResult(); await release.Task; return 1; });
await entered.Task;
var contenders = Enumerable.Range(0, 64).Select(async _ => {
try { await Gate("task:1", () => Task.FromResult(2)); return false; }
catch (InvalidOperationException) { return true; }
}).ToArray();
Check((await Task.WhenAll(contenders)).All(x => x), "64 independent same-task operations rejected while held");
Check(await Gate("task:2", () => Task.FromResult(7)) == 7, "different tasks proceed independently");
release.SetResult();
await first;
Check(await Gate("task:1", () => Task.FromResult(3)) == 3, "lock released after completion");
var before = leases.AcquisitionCount;
Check(await Gate("nested", () => Gate("nested", () => Gate("identity", () => Gate("nested", () => Task.FromResult(9))))) == 9,
"nested task/snapshot call is reentrant");
Check(leases.AcquisitionCount - before == 2, "reentrant gate acquires each resource only once");
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")]
public class TestRow { public long Id { get; set; } public string Name { get; set; } }
public sealed class FakeLeases {
private readonly ConcurrentDictionary<string, byte> held = new();
private int acquisitions;
public int AcquisitionCount => acquisitions;
public int ActiveCount => held.Count;
public Task<IAsyncDisposable> Acquire(string key) {
if (!held.TryAdd(key, 0)) throw new InvalidOperationException("busy");
Interlocked.Increment(ref acquisitions);
return Task.FromResult<IAsyncDisposable>(new Lease(() => held.TryRemove(key, out _)));
}
private sealed class Lease(Action release) : IAsyncDisposable {
public ValueTask DisposeAsync() { release(); return ValueTask.CompletedTask; }
}
}
+40
View File
@@ -0,0 +1,40 @@
# 第 2、5 项安全修复离线回归
本测试直接链接生产代码的 BasePageInput、OnboardingOperationGate、OnboardingSignStateGuard 和状态常量。
不启动业务程序、不连接业务数据库、不联系契约锁,也不查询在线漏洞库。
SqlSugar 只生成 SQL。互斥测试使用模拟的外部租约,不代表 MySQL 集成测试。
## 运行
先在后端构建相应 net6.0 项目,确保 SimpleAdmin.Web.Entry/bin/Debug/net6.0 下已有 SqlSugar.dll 和 Newtonsoft.Json.dll。
测试需要本机已安装 .NET 8 SDK/运行时。
在后端目录执行:
```powershell
dotnet run --project tests/SecurityRegression/SecurityRegression.csproj -p:NuGetAudit=false -p:UseSharedCompilation=false
```
测试使用本地 empty-feed,不向外网发送依赖信息。无新增 NuGet 包。
框架校验属性和友好异常由 Stubs.cs 替代;核心排序、异步重入和状态迁移逻辑直接引用源文件。
## 覆盖
- SQL 排序表达式、注释、分号、制表符、换行、未知字段被拒绝。
- asc/desc/ascend/descend、大小写及空排序方向兼容。
- 安全排序由本地 ORM 生成 SQL。
- 签约终态、防回退、归档状态保持。
- 64 个竞争调用与同任务互斥;不同任务互不阻塞。
- 同调用链嵌套任务锁可重入;正常/异常结束释放锁;调用方上下文不泄漏。
- 按版本选择公司盖章及骑缝章:四份材料全部员工签字,仅指定两份盖章。
- 单份/合并骑缝章只覆盖已选文件;两个开关独立;历史空值兼容全局设置。
- 纯员工签字的草稿无企业盖章节点;混合材料仍由后台人工触发企业盖章。
- 版本盖章开关局部更新:开启/关闭均保留布尔值,只更新传入的字段,版本 ID 只用作条件,不更新主键、另一个开关或版本状态;空请求被拒绝。
- 局部更新测试直接链接生产 SQL 构造代码和请求 DTO;版本实体仅使用带主键映射的测试替身,不代表真实数据库写入或权限集成测试。
- 请求构造器直接链接生产代码,只校验构造出的参数,不向契约锁发起真实签约。
## 仍需测试环境验证
MySQL GET_LOCK/RELEASE_LOCK 的多会话、多实例互斥、实际业务事务回滚、
提交与审核/撤销竞争、转正与编辑竞争、重复/乱序契约锁回调、企业盖章网络断开及中断恢复。
不得将这套离线用例当成真实平台压测或数据库集成测试结果。
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks></TargetFrameworks><OutputType>Exe</OutputType><TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings><NuGetAudit>false</NuGetAudit>
<RestoreSources>$(MSBuildThisFileDirectory)empty-feed</RestoreSources>
</PropertyGroup>
<ItemGroup>
<Reference Include="SqlSugar"><HintPath>../../SimpleAdmin.Web.Entry/bin/Debug/net6.0/SqlSugar.dll</HintPath></Reference>
<Reference Include="Newtonsoft.Json"><HintPath>../../SimpleAdmin.Web.Entry/bin/Debug/net6.0/Newtonsoft.Json.dll</HintPath></Reference>
<Compile Include="../../SimpleAdmin.Core/BaseInput/BasePageInput.cs" Link="BasePageInput.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/Common/OnboardingOperationGate.cs" Link="OnboardingOperationGate.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingSignStateGuard.cs" Link="OnboardingSignStateGuard.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/Common/ContractTemplateVersionValidity.cs" Link="ContractTemplateVersionValidity.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/Common/ContractTemplateVersionSealConfiguration.cs" Link="ContractTemplateVersionSealConfiguration.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractVersionInput.cs" Link="OnboardingContractVersionInput.cs"/>
<Compile Include="../../SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs" Link="OnboardingStatus.cs"/>
<Compile Include="../../SimpleAdmin.Application/Options/QiyuesuoSettingOptions.cs" Link="QiyuesuoSettingOptions.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClientModels.cs" Link="QiyuesuoClientModels.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoContractRequestBuilder.cs" Link="QiyuesuoContractRequestBuilder.cs"/>
</ItemGroup>
</Project>
+22
View File
@@ -0,0 +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); }
[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; }
}
}