From 95b14b198a16f11caad5dfcb419fbaca67ccde39 Mon Sep 17 00:00:00 2001
From: fuenmao <980740792@qq.com>
Date: Wed, 9 Sep 2026 14:57:41 +0800
Subject: [PATCH] 111
---
.../Entity/Onboarding/ObEmployee.cs | 19 +-
.../Entity/Onboarding/ObEmployeeAttachment.cs | 28 +
.../Onboarding/ObEmployeeChangeRecord.cs | 27 +
.../Onboarding/ObEmployeeContractSnapshot.cs | 89 ++
.../Entity/Onboarding/ObOnboardingTask.cs | 7 +-
.../Entity/Onboarding/OnboardingStatus.cs | 51 +-
.../Audit/OnboardingAuditService.cs | 15 +-
.../Common/OnboardingCodeValidator.cs | 29 +
.../Company/OnboardingCompanyService.cs | 6 +
.../OnboardingContractTemplateService.cs | 5 +-
.../Department/OnboardingDepartmentService.cs | 7 +-
.../OnboardingElectronicSignService.cs | 10 +-
.../Employee/Dto/OnboardingEmployeeInput.cs | 92 ++
.../Employee/IOnboardingEmployeeService.cs | 18 +-
.../Employee/OnboardingEmployeeService.cs | 1021 ++++++++++++++++-
...boardingEmployeeContractSnapshotService.cs | 10 +
...boardingEmployeeContractSnapshotService.cs | 153 +++
.../OnboardingEntryConfigService.cs | 11 +-
.../OnboardingTemplateService.cs | 35 +-
.../Position/OnboardingPositionService.cs | 7 +-
.../Task/Dto/OnboardingTaskInput.cs | 5 +
.../Onboarding/Task/OnboardingTaskService.cs | 15 +
.../OnboardingEmployeeController.cs | 85 +-
.../20260908_employee_contract_snapshot.sql | 163 +++
.../20260909_employee_hr_management.sql | 172 +++
...0260828_reset_onboarding_business_data.sql | 29 +
...set_onboarding_personnel_and_sign_data.sql | 12 +
27 files changed, 2069 insertions(+), 52 deletions(-)
create mode 100644 SimpleAdmin.Application/Entity/Onboarding/ObEmployeeAttachment.cs
create mode 100644 SimpleAdmin.Application/Entity/Onboarding/ObEmployeeChangeRecord.cs
create mode 100644 SimpleAdmin.Application/Entity/Onboarding/ObEmployeeContractSnapshot.cs
create mode 100644 SimpleAdmin.Application/Services/Onboarding/Common/OnboardingCodeValidator.cs
create mode 100644 SimpleAdmin.Application/Services/Onboarding/EmployeeContractSnapshot/IOnboardingEmployeeContractSnapshotService.cs
create mode 100644 SimpleAdmin.Application/Services/Onboarding/EmployeeContractSnapshot/OnboardingEmployeeContractSnapshotService.cs
create mode 100644 database/migrations/20260908_employee_contract_snapshot.sql
create mode 100644 database/migrations/20260909_employee_hr_management.sql
diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObEmployee.cs b/SimpleAdmin.Application/Entity/Onboarding/ObEmployee.cs
index d12d3c0..3cc9f14 100644
--- a/SimpleAdmin.Application/Entity/Onboarding/ObEmployee.cs
+++ b/SimpleAdmin.Application/Entity/Onboarding/ObEmployee.cs
@@ -11,9 +11,9 @@
namespace SimpleAdmin.Application;
///
-/// 审核通过后生成的正式员工档案,是入职流程的最终业务数据。
+/// 审核通过后生成的员工档案,是入职流程的最终业务数据。
///
-[SugarTable("ob_employee", TableDescription = "正式员工档案")]
+[SugarTable("ob_employee", TableDescription = "员工档案")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObEmployee : BaseEntity
{
@@ -59,6 +59,21 @@ public class ObEmployee : BaseEntity
///
public long PositionId { get; set; }
+ ///
+ /// 用工类型:full_time 正式用工,part_time 兼职用工。
+ ///
+ public string EmploymentType { get; set; } = EmployeeEmploymentType.FullTime;
+
+ ///
+ /// 转正状态:probation 试用期、regular 已转正、not_applicable 不适用。
+ ///
+ public string RegularizationStatus { get; set; } = EmployeeRegularizationStatus.Probation;
+
+ ///
+ /// 社保状态:unknown 未维护、not_purchased 未购买、purchased 已购买、stopped 已停保、not_applicable 不适用。
+ ///
+ public string SocialInsuranceStatus { get; set; } = EmployeeSocialInsuranceStatus.Unknown;
+
///
/// 所属岗位名称,仅用于接口展示,不映射数据库字段。
///
diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObEmployeeAttachment.cs b/SimpleAdmin.Application/Entity/Onboarding/ObEmployeeAttachment.cs
new file mode 100644
index 0000000..f938beb
--- /dev/null
+++ b/SimpleAdmin.Application/Entity/Onboarding/ObEmployeeAttachment.cs
@@ -0,0 +1,28 @@
+namespace SimpleAdmin.Application;
+
+///
+/// 员工人事材料附件,可关联转正记录及后续扩展的人事业务。
+///
+[SugarTable("ob_employee_attachment", TableDescription = "员工人事材料附件")]
+[Tenant(SqlSugarConst.DB_DEFAULT)]
+public class ObEmployeeAttachment : BaseEntity
+{
+ public long EmployeeId { get; set; }
+
+ [SugarColumn(IsNullable = true)]
+ public long? ChangeRecordId { get; set; }
+
+ public string BusinessType { get; set; }
+
+ public long FileId { get; set; }
+
+ public string FileName { get; set; }
+
+ public DateTime UploadedAt { get; set; }
+
+ [SugarColumn(IsIgnore = true)]
+ public string FileSuffix { get; set; }
+
+ [SugarColumn(IsIgnore = true)]
+ public string FileSizeInfo { get; set; }
+}
diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObEmployeeChangeRecord.cs b/SimpleAdmin.Application/Entity/Onboarding/ObEmployeeChangeRecord.cs
new file mode 100644
index 0000000..8bcb97d
--- /dev/null
+++ b/SimpleAdmin.Application/Entity/Onboarding/ObEmployeeChangeRecord.cs
@@ -0,0 +1,27 @@
+namespace SimpleAdmin.Application;
+
+///
+/// 员工人事信息变更记录,保留转正及后续扩展业务的前后快照。
+///
+[SugarTable("ob_employee_change_record", TableDescription = "员工人事信息变更记录")]
+[Tenant(SqlSugarConst.DB_DEFAULT)]
+public class ObEmployeeChangeRecord : BaseEntity
+{
+ public long EmployeeId { get; set; }
+
+ public string ChangeType { get; set; }
+
+ [SugarColumn(IsNullable = true)]
+ public DateTime? EffectiveDate { get; set; }
+
+ [SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
+ public string BeforeJson { get; set; }
+
+ [SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
+ public string AfterJson { get; set; }
+
+ public string Remark { get; set; }
+
+ [SugarColumn(IsIgnore = true)]
+ public List Attachments { get; set; } = new();
+}
diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObEmployeeContractSnapshot.cs b/SimpleAdmin.Application/Entity/Onboarding/ObEmployeeContractSnapshot.cs
new file mode 100644
index 0000000..29cfa6b
--- /dev/null
+++ b/SimpleAdmin.Application/Entity/Onboarding/ObEmployeeContractSnapshot.cs
@@ -0,0 +1,89 @@
+namespace SimpleAdmin.Application;
+
+///
+/// 正式员工的一份合同在审核建档时冻结的业务快照。
+/// 常用日期和薪资使用独立列,全部标准及自定义变量同时保存在 VariablesJson 中。
+///
+[SugarTable("ob_employee_contract_snapshot", TableDescription = "员工合同快照")]
+[Tenant(SqlSugarConst.DB_DEFAULT)]
+public class ObEmployeeContractSnapshot : BaseEntity
+{
+ /// 正式员工 Id。
+ public long EmployeeId { get; set; }
+
+ /// 来源入职任务 Id。
+ public long TaskId { get; set; }
+
+ /// 来源任务合同 Id,同一任务合同只允许生成一份员工快照。
+ public long TaskContractId { get; set; }
+
+ /// 合同模板 Id。
+ public long TemplateId { get; set; }
+
+ /// 合同模板版本 Id。
+ public long TemplateVersionId { get; set; }
+
+ /// 合同名称快照。
+ public string ContractName { get; set; }
+
+ /// 合同类型快照。
+ public string ContractType { get; set; }
+
+ /// 合同开始日期。
+ public DateTime? ContractStartDate { get; set; }
+
+ /// 合同结束日期。
+ public DateTime? ContractEndDate { get; set; }
+
+ /// 合同期限原始文本。
+ public string ContractTerm { get; set; }
+
+ /// 试用期开始日期。
+ public DateTime? ProbationStartDate { get; set; }
+
+ /// 试用期结束日期。
+ public DateTime? ProbationEndDate { get; set; }
+
+ /// 试用期期限原始文本。
+ public string ProbationTerm { get; set; }
+
+ /// 明确使用 hr.ProbationSalary 保存的试用薪资;不从其他工资项目推算。
+ public decimal? ProbationSalary { get; set; }
+
+ /// 基本工资。
+ public decimal? BaseSalary { get; set; }
+
+ /// 岗位工资。
+ public decimal? PositionSalary { get; set; }
+
+ /// 绩效津贴。
+ public decimal? PerformanceAllowance { get; set; }
+
+ /// 全勤奖。
+ public decimal? AttendanceBonus { get; set; }
+
+ /// 转正后薪资。
+ public decimal? RegularSalary { get; set; }
+
+ /// 工作地点或城市。
+ public string WorkCity { get; set; }
+
+ /// 合同中的岗位名称。
+ public string PositionName { get; set; }
+
+ /// 合同中的员工工号。
+ public string EmployeeNo { get; set; }
+
+ ///
+ /// 以 VariableCode 为键保存该合同当时的全部变量元数据和值。
+ /// 导出时通过稳定编码判断变量是否存在,不依赖可修改的中文名称。
+ ///
+ [SugarColumn(ColumnDataType = "json", IsNullable = false)]
+ public string VariablesJson { get; set; }
+
+ /// 快照结构版本。
+ public int SnapshotVersion { get; set; } = 1;
+
+ /// 快照生成时间。
+ public DateTime SnapshotAt { get; set; }
+}
diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTask.cs b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTask.cs
index c8f9c8e..9de1509 100644
--- a/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTask.cs
+++ b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTask.cs
@@ -33,6 +33,11 @@ public class ObOnboardingTask : BaseEntity
///
public long PositionId { get; set; }
+ ///
+ /// 用工类型:full_time 正式用工,part_time 兼职用工。
+ ///
+ public string EmploymentType { get; set; } = EmployeeEmploymentType.FullTime;
+
///
/// 创建任务时锁定的入职模板 Id。
///
@@ -106,4 +111,4 @@ public class ObOnboardingTask : BaseEntity
///
[SugarColumn(IsIgnore = true)]
public string OnboardingTemplateName { get; set; }
-}
\ No newline at end of file
+}
diff --git a/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs b/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs
index e773c6f..4c86e95 100644
--- a/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs
+++ b/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs
@@ -36,7 +36,7 @@ public static class OnboardingTaskStatus
public const string Rejected = "rejected";
///
- /// 已通过,系统已经生成正式员工档案。
+ /// 已通过,系统已经生成员工档案。
///
public const string Approved = "approved";
@@ -144,6 +144,55 @@ public static class TaskContractStatus
public const string Archived = "archived";
}
+///
+/// 员工用工类型。
+///
+public static class EmployeeEmploymentType
+{
+ public const string FullTime = "full_time";
+ public const string PartTime = "part_time";
+}
+
+///
+/// 员工转正状态,仅由后台人事操作改变,不根据合同日期自动推算。
+///
+public static class EmployeeRegularizationStatus
+{
+ public const string Probation = "probation";
+ public const string Regular = "regular";
+ public const string NotApplicable = "not_applicable";
+}
+
+///
+/// 员工社保状态。
+///
+public static class EmployeeSocialInsuranceStatus
+{
+ public const string Unknown = "unknown";
+ public const string NotPurchased = "not_purchased";
+ public const string Purchased = "purchased";
+ public const string Stopped = "stopped";
+ public const string NotApplicable = "not_applicable";
+}
+
+///
+/// 员工人事变更类型。
+///
+public static class EmployeeChangeType
+{
+ public const string HrInfoUpdate = "hr_info_update";
+ public const string Regularization = "regularization";
+ public const string RegularizationRevoke = "regularization_revoke";
+}
+
+///
+/// 员工附件业务类型。
+///
+public static class EmployeeAttachmentBusinessType
+{
+ public const string Regularization = "regularization";
+}
+
///
/// 电子签服务商。
///
diff --git a/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs b/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs
index 660f42c..32bc5e6 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs
@@ -8,14 +8,17 @@ public class OnboardingAuditService : IOnboardingAuditService
private readonly SqlSugarScope _db;
private readonly IConfigService _config;
private readonly IOnboardingContractSnapshotService _snapshotService;
+ private readonly IOnboardingEmployeeContractSnapshotService _employeeContractSnapshotService;
public OnboardingAuditService(
IConfigService config,
- IOnboardingContractSnapshotService snapshotService)
+ IOnboardingContractSnapshotService snapshotService,
+ IOnboardingEmployeeContractSnapshotService employeeContractSnapshotService)
{
_db = DbContext.DB;
_config = config;
_snapshotService = snapshotService;
+ _employeeContractSnapshotService = employeeContractSnapshotService;
}
///
@@ -51,6 +54,13 @@ public class OnboardingAuditService : IOnboardingAuditService
employee.CompanyId = task.CompanyId;
employee.DepartmentId = task.DepartmentId;
employee.PositionId = task.PositionId;
+ employee.EmploymentType = string.IsNullOrWhiteSpace(task.EmploymentType)
+ ? EmployeeEmploymentType.FullTime
+ : task.EmploymentType;
+ employee.RegularizationStatus = employee.EmploymentType == EmployeeEmploymentType.PartTime
+ ? EmployeeRegularizationStatus.NotApplicable
+ : EmployeeRegularizationStatus.Probation;
+ employee.SocialInsuranceStatus = EmployeeSocialInsuranceStatus.Unknown;
if (task.DepartmentId.HasValue)
{
var dept = await _db.Queryable().InSingleAsync(task.DepartmentId.Value);
@@ -96,6 +106,7 @@ public class OnboardingAuditService : IOnboardingAuditService
await _snapshotService.Build(task.Id, employeeInfo);
await _db.Insertable(employee).ExecuteCommandAsync();
+ await _employeeContractSnapshotService.Build(employee.Id, task.Id);
task.EmployeeId = employee.Id;
task.TaskStatus = OnboardingTaskStatus.Approved;
@@ -197,7 +208,7 @@ public class OnboardingAuditService : IOnboardingAuditService
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalizedValues.Count == 0)
- throw Oops.Bah("签署材料参数“合同开始日期”尚未填写,无法生成正式员工档案");
+ throw Oops.Bah("签署材料参数“合同开始日期”尚未填写,无法生成员工档案");
var parsedDates = new List();
foreach (var value in normalizedValues)
diff --git a/SimpleAdmin.Application/Services/Onboarding/Common/OnboardingCodeValidator.cs b/SimpleAdmin.Application/Services/Onboarding/Common/OnboardingCodeValidator.cs
new file mode 100644
index 0000000..618fe54
--- /dev/null
+++ b/SimpleAdmin.Application/Services/Onboarding/Common/OnboardingCodeValidator.cs
@@ -0,0 +1,29 @@
+namespace SimpleAdmin.Application;
+
+///
+/// 统一校验入职业务中的可编辑编码,避免非法字符或超长内容落到数据库后才报 500。
+///
+internal static class OnboardingCodeValidator
+{
+ ///
+ /// 规范化并校验业务编码。编码必须以字母开头,可包含字母、数字、点、下划线和短横线。
+ ///
+ public static string Normalize(string code, string fieldName, int maxLength)
+ {
+ var normalized = code?.Trim();
+ if (string.IsNullOrWhiteSpace(normalized))
+ throw Oops.Bah($"{fieldName}不能为空");
+
+ if (normalized.Length > maxLength)
+ throw Oops.Bah($"{fieldName}长度不能超过 {maxLength} 个字符");
+
+ if (!global::System.Text.RegularExpressions.Regex.IsMatch(
+ normalized,
+ @"^[A-Za-z][A-Za-z0-9._-]*$"))
+ {
+ throw Oops.Bah($"{fieldName}必须以字母开头,只能包含字母、数字、点、下划线和短横线");
+ }
+
+ return normalized;
+ }
+}
diff --git a/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs b/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs
index e802d76..3f2a9ac 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs
@@ -102,6 +102,12 @@ public class OnboardingCompanyService : DbRepository, IOnboardingComp
private static void NormalizeAndValidateQiyuesuoConfig(ObCompany company)
{
+ company.CompanyCode = OnboardingCodeValidator.Normalize(company.CompanyCode, "公司编码", 50);
+ company.CompanyName = company.CompanyName?.Trim();
+ company.LegalName = company.LegalName?.Trim();
+ if (string.IsNullOrWhiteSpace(company.CompanyName))
+ throw Oops.Bah("公司名称不能为空");
+
company.QiyuesuoCategoryId = company.QiyuesuoCategoryId?.Trim();
company.QiyuesuoSealId = company.QiyuesuoSealId?.Trim();
diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs
index 92babaf..1abbfb6 100644
--- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs
@@ -450,13 +450,12 @@ public class OnboardingContractTemplateService : DbRepository
private static void NormalizeInput(OnboardingContractTemplateAddInput input)
{
- input.TemplateCode = input.TemplateCode?.Trim();
+ input.TemplateCode = OnboardingCodeValidator.Normalize(input.TemplateCode, "签署材料模板编码", 50);
input.TemplateName = input.TemplateName?.Trim();
input.ContractType = input.ContractType?.Trim();
input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List();
- if (string.IsNullOrWhiteSpace(input.TemplateCode)
- || string.IsNullOrWhiteSpace(input.TemplateName)
+ if (string.IsNullOrWhiteSpace(input.TemplateName)
|| string.IsNullOrWhiteSpace(input.ContractType))
{
throw Oops.Bah("请填写模板编码、模板名称和合同类型");
diff --git a/SimpleAdmin.Application/Services/Onboarding/Department/OnboardingDepartmentService.cs b/SimpleAdmin.Application/Services/Onboarding/Department/OnboardingDepartmentService.cs
index bbb935d..ea8f215 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Department/OnboardingDepartmentService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Department/OnboardingDepartmentService.cs
@@ -133,12 +133,11 @@ public class OnboardingDepartmentService : DbRepository, IOnboardi
///
private static void NormalizeInput(OnboardingDepartmentAddInput input)
{
- input.DepartmentCode = input.DepartmentCode?.Trim();
+ input.DepartmentCode = OnboardingCodeValidator.Normalize(input.DepartmentCode, "部门编码", 50);
input.DepartmentName = input.DepartmentName?.Trim();
- if (string.IsNullOrWhiteSpace(input.DepartmentCode)
- || string.IsNullOrWhiteSpace(input.DepartmentName))
+ if (string.IsNullOrWhiteSpace(input.DepartmentName))
{
- throw Oops.Bah("请填写部门编码和部门名称");
+ throw Oops.Bah("请填写部门名称");
}
if (input.Status != "enable" && input.Status != "disable")
diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs
index 3104574..a7e68a9 100644
--- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs
@@ -138,7 +138,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
{
var employee = await _db.Queryable()
.InSingleAsync(task.EmployeeId.Value)
- ?? throw Oops.Bah("正式员工档案不存在,不能发起电子签");
+ ?? throw Oops.Bah("员工档案不存在,不能发起电子签");
if (string.IsNullOrWhiteSpace(employee.EmployeeName))
throw Oops.Bah("正式员工姓名为空,不能发起电子签");
if (string.IsNullOrWhiteSpace(employee.Phone))
@@ -1417,7 +1417,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
}
if (differentFields.Count > 0)
- throw Oops.Bah($"正式员工档案的{string.Join("、", differentFields)}与审核通过的入职登记资料不一致,请先核对并修正后再发起电子签");
+ throw Oops.Bah($"员工档案的{string.Join("、", differentFields)}与审核通过的入职登记资料不一致,请先核对并修正后再发起电子签");
}
private static string NormalizeIdentityValue(string value)
@@ -1468,7 +1468,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
private static void ValidateTaskCanSign(ObOnboardingTask task)
{
if (!task.EmployeeId.HasValue)
- throw Oops.Bah("入职任务尚未生成正式员工档案,不能发起电子签");
+ throw Oops.Bah("入职任务尚未生成员工档案,不能发起电子签");
if (task.TaskStatus != OnboardingTaskStatus.Approved
&& task.TaskStatus != OnboardingTaskStatus.Archived)
{
@@ -1527,7 +1527,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
bool processAllowed)
{
if (!task.EmployeeId.HasValue)
- return "审核通过并生成正式员工档案后才可发起电子签";
+ return "审核通过并生成员工档案后才可发起电子签";
if (!taskAllowed)
return "当前入职任务状态不能发起电子签";
if (!processAllowed)
@@ -1542,7 +1542,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
bool reinitiateAllowed)
{
if (!task.EmployeeId.HasValue)
- return "审核通过并生成正式员工档案后才可重新发起电子签";
+ return "审核通过并生成员工档案后才可重新发起电子签";
if (!taskAllowed)
return "当前入职任务状态不能重新发起电子签";
if (process == null)
diff --git a/SimpleAdmin.Application/Services/Onboarding/Employee/Dto/OnboardingEmployeeInput.cs b/SimpleAdmin.Application/Services/Onboarding/Employee/Dto/OnboardingEmployeeInput.cs
index 54c2d04..7e51d96 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Employee/Dto/OnboardingEmployeeInput.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Employee/Dto/OnboardingEmployeeInput.cs
@@ -1,3 +1,5 @@
+using System.ComponentModel.DataAnnotations;
+
namespace SimpleAdmin.Application;
///
@@ -29,6 +31,84 @@ public class OnboardingEmployeePageInput : BasePageInput
/// 岗位 Id。
///
public long? PositionId { get; set; }
+
+ ///
+ /// 用工类型。
+ ///
+ public string EmploymentType { get; set; }
+
+ ///
+ /// 转正状态。
+ ///
+ public string RegularizationStatus { get; set; }
+
+ ///
+ /// 社保状态。
+ ///
+ public string SocialInsuranceStatus { get; set; }
+}
+
+///
+/// 编辑员工人事信息。
+///
+public class OnboardingEmployeeHrUpdateInput
+{
+ [IdNotNull(ErrorMessage = "员工Id不能为空")]
+ public long Id { get; set; }
+
+ [Required(ErrorMessage = "请选择用工类型")]
+ public string EmploymentType { get; set; }
+
+ public string ContractNature { get; set; }
+
+ [Required(ErrorMessage = "请选择社保状态")]
+ public string SocialInsuranceStatus { get; set; }
+
+ public string SocialInsuranceCompany { get; set; }
+}
+
+///
+/// 员工转正办理参数。
+///
+public class OnboardingEmployeeRegularizeInput
+{
+ [IdNotNull(ErrorMessage = "员工Id不能为空")]
+ public long Id { get; set; }
+
+ [Required(ErrorMessage = "请选择转正日期")]
+ public DateTime? RegularizationDate { get; set; }
+
+ public string Remark { get; set; }
+
+ public List FileIds { get; set; } = new();
+}
+
+///
+/// 撤销员工转正参数。
+///
+public class OnboardingEmployeeRegularizationRevokeInput
+{
+ [IdNotNull(ErrorMessage = "员工Id不能为空")]
+ public long Id { get; set; }
+
+ [Required(ErrorMessage = "请填写撤销原因")]
+ public string Remark { get; set; }
+}
+
+///
+/// 正式员工档案导出结果。
+///
+public class OnboardingEmployeeExportFile
+{
+ ///
+ /// Excel 文件字节。
+ ///
+ public byte[] FileBytes { get; set; }
+
+ ///
+ /// 下载文件名。
+ ///
+ public string FileName { get; set; }
}
///
@@ -56,6 +136,12 @@ public class OnboardingEmployeeDetailOutput
///
public List Contracts { get; set; } = new List();
+ ///
+ /// 审核建档时冻结的员工合同业务快照,包含常用字段及全部自定义变量。
+ ///
+ public List ContractSnapshots { get; set; }
+ = new List();
+
///
/// 员工入职时上传的身份证、学历证明、体检报告等资料。
///
@@ -71,4 +157,10 @@ public class OnboardingEmployeeDetailOutput
/// 提交与审核记录列表。
///
public List Audits { get; set; } = new List();
+
+ ///
+ /// 员工转正及人事维护历史。
+ ///
+ public List ChangeRecords { get; set; }
+ = new List();
}
diff --git a/SimpleAdmin.Application/Services/Onboarding/Employee/IOnboardingEmployeeService.cs b/SimpleAdmin.Application/Services/Onboarding/Employee/IOnboardingEmployeeService.cs
index a7740e8..52036b9 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Employee/IOnboardingEmployeeService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Employee/IOnboardingEmployeeService.cs
@@ -1,7 +1,7 @@
namespace SimpleAdmin.Application;
///
-/// 定义正式员工档案查询业务能力。
+/// 定义员工档案查询和人事维护业务能力。
///
public interface IOnboardingEmployeeService : ITransient
{
@@ -10,6 +10,22 @@ public interface IOnboardingEmployeeService : ITransient
///
Task> Page(OnboardingEmployeePageInput input);
///
+ /// 按当前列表筛选条件导出员工明细。
+ ///
+ Task Export(OnboardingEmployeePageInput input);
+ ///
+ /// 编辑员工用工、社保及合同性质等人事信息。
+ ///
+ Task UpdateHrInfo(OnboardingEmployeeHrUpdateInput input);
+ ///
+ /// 人工办理员工转正。
+ ///
+ Task Regularize(OnboardingEmployeeRegularizeInput input);
+ ///
+ /// 撤销一次员工转正,历史记录和附件继续保留。
+ ///
+ Task RevokeRegularization(OnboardingEmployeeRegularizationRevokeInput input);
+ ///
/// 查询正式员工及其来源任务、合同和审核记录。
///
Task Detail(BaseIdInput input);
diff --git a/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs b/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs
index 8c0661f..824c062 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs
@@ -1,7 +1,12 @@
+using OfficeOpenXml;
+using OfficeOpenXml.Style;
+using SixLabors.ImageSharp;
+using System.Text.Json;
+
namespace SimpleAdmin.Application;
///
-/// 实现正式员工档案查询业务能力,仅展示审核通过后创建的正式员工。
+/// 实现员工档案查询、人事状态维护和明细导出能力。
///
public class OnboardingEmployeeService : IOnboardingEmployeeService
{
@@ -13,19 +18,11 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
}
///
- /// 分页查询审核通过后生成的正式员工档案。
+ /// 分页查询审核通过后生成的员工档案。
///
public async Task> Page(OnboardingEmployeePageInput input)
{
- var result = await _db.Queryable()
- .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 result = await BuildEmployeeQuery(input)
.OrderByDescending(item => item.CreateTime)
.ToPagedListAsync(input.PageNum, input.PageSize);
@@ -35,13 +32,91 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
return result;
}
+ ///
+ public async Task Export(OnboardingEmployeePageInput input)
+ {
+ input ??= new OnboardingEmployeePageInput();
+ var employees = await BuildEmployeeQuery(input)
+ .OrderBy(item => item.CompanyId)
+ .OrderBy(item => item.DepartmentId)
+ .OrderBy(item => item.PositionId)
+ .OrderBy(item => item.EmployeeName)
+ .ToListAsync();
+ if (employees.Count == 0)
+ throw Oops.Bah("当前筛选条件没有可导出的员工");
+
+ await FillEmployeeMetadata(employees);
+ var employeeIds = employees.Select(item => item.Id).ToList();
+ var tasks = await _db.Queryable()
+ .Where(item => item.EmployeeId.HasValue
+ && employeeIds.Contains(item.EmployeeId.Value))
+ .OrderByDescending(item => item.CreateTime)
+ .ToListAsync();
+ var taskIds = tasks.Select(item => item.Id).Distinct().ToList();
+ var employeeInfos = taskIds.Count == 0
+ ? new List()
+ : await _db.Queryable()
+ .Where(item => taskIds.Contains(item.TaskId))
+ .ToListAsync();
+ var snapshots = await _db.Queryable()
+ .Where(item => employeeIds.Contains(item.EmployeeId))
+ .OrderBy(item => item.EmployeeId)
+ .OrderBy(item => item.SnapshotAt)
+ .OrderBy(item => item.Id)
+ .ToListAsync();
+
+ var taskByEmployeeId = tasks
+ .Where(item => item.EmployeeId.HasValue)
+ .GroupBy(item => item.EmployeeId.Value)
+ .ToDictionary(group => group.Key, group => group.First());
+ var employeeInfoByTaskId = employeeInfos
+ .GroupBy(item => item.TaskId)
+ .ToDictionary(group => group.Key, group => group.First());
+ var snapshotsByEmployeeId = snapshots
+ .GroupBy(item => item.EmployeeId)
+ .ToDictionary(group => group.Key, group => group.ToList());
+
+ var employeeRows = employees.Select(employee =>
+ {
+ var info = DeserializeEmployeeInfo(employee.ExtraInfoJson);
+ if (info == null
+ && taskByEmployeeId.TryGetValue(employee.Id, out var task)
+ && employeeInfoByTaskId.TryGetValue(task.Id, out var taskInfo))
+ {
+ info = taskInfo;
+ }
+
+ var employeeSnapshots = snapshotsByEmployeeId.GetValueOrDefault(employee.Id)
+ ?? new List();
+ return new EmployeeExportContext
+ {
+ Employee = employee,
+ EmployeeInfo = info,
+ PrimaryContract = SelectPrimaryContract(employeeSnapshots)
+ };
+ }).ToList();
+
+ using var package = new ExcelPackage();
+ package.Workbook.Properties.Title = "员工明细";
+ package.Workbook.Properties.Subject = "员工基本、人事状态及主要合同信息";
+ package.Workbook.Properties.Created = DateTime.Now;
+
+ WriteEmployeeSheet(package.Workbook.Worksheets.Add("员工明细"), employeeRows);
+
+ return new OnboardingEmployeeExportFile
+ {
+ FileBytes = package.GetAsByteArray(),
+ FileName = $"员工明细_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx"
+ };
+ }
+
///
/// 查询正式员工及其来源任务、合同和审核记录。
///
public async Task Detail(BaseIdInput input)
{
var employee = await _db.Queryable().InSingleAsync(input.Id)
- ?? throw Oops.Bah("正式员工不存在");
+ ?? throw Oops.Bah("员工档案不存在");
var task = await _db.Queryable().FirstAsync(x => x.EmployeeId == employee.Id);
await FillEmployeeMetadata(new[] { employee });
@@ -67,6 +142,11 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
: await _db.Queryable()
.Where(item => item.TaskId == task.Id)
.ToListAsync();
+ var contractSnapshots = await _db.Queryable()
+ .Where(item => item.EmployeeId == employee.Id)
+ .OrderBy(item => item.CreateTime)
+ .OrderBy(item => item.Id)
+ .ToListAsync();
var materials = await GetEmployeeMaterials(task?.Id);
var electronicSignProcess = task == null
? null
@@ -80,6 +160,7 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
.Where(item => item.TaskId == task.Id)
.OrderByDescending(item => item.AuditedAt)
.ToListAsync();
+ var changeRecords = await GetEmployeeChangeRecords(employee.Id);
return new OnboardingEmployeeDetailOutput
{
@@ -87,12 +168,246 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
Task = task,
EmployeeInfo = employeeInfo,
Contracts = contracts,
+ ContractSnapshots = contractSnapshots,
Materials = materials,
ElectronicSignArchiveFileId = electronicSignProcess?.ArchiveFileId,
- Audits = audits
+ Audits = audits,
+ ChangeRecords = changeRecords
};
}
+ ///
+ public async Task UpdateHrInfo(OnboardingEmployeeHrUpdateInput input)
+ {
+ ValidateEmploymentType(input.EmploymentType);
+ ValidateSocialInsuranceStatus(input.SocialInsuranceStatus);
+
+ var employee = await _db.Queryable().InSingleAsync(input.Id)
+ ?? throw Oops.Bah("员工档案不存在");
+ var before = CreateHrState(employee);
+ var regularizationStatus = employee.RegularizationStatus;
+ var regularizationDate = employee.RegularizationDate;
+ if (input.EmploymentType == EmployeeEmploymentType.PartTime)
+ {
+ regularizationStatus = EmployeeRegularizationStatus.NotApplicable;
+ regularizationDate = null;
+ }
+ else if (employee.EmploymentType == EmployeeEmploymentType.PartTime
+ || string.IsNullOrWhiteSpace(regularizationStatus)
+ || regularizationStatus == EmployeeRegularizationStatus.NotApplicable)
+ {
+ regularizationStatus = EmployeeRegularizationStatus.Probation;
+ regularizationDate = null;
+ }
+
+ var contractNature = NormalizeOptionalText(input.ContractNature);
+ var socialInsuranceCompany = NormalizeOptionalText(input.SocialInsuranceCompany);
+ var noChanges = employee.EmploymentType == input.EmploymentType
+ && employee.RegularizationStatus == regularizationStatus
+ && employee.RegularizationDate == regularizationDate
+ && string.Equals(employee.ContractNature, contractNature, StringComparison.Ordinal)
+ && employee.SocialInsuranceStatus == input.SocialInsuranceStatus
+ && string.Equals(
+ employee.SocialInsuranceCompany,
+ socialInsuranceCompany,
+ StringComparison.Ordinal);
+ if (noChanges)
+ return;
+
+ var after = new
+ {
+ employmentType = input.EmploymentType,
+ regularizationStatus,
+ regularizationDate,
+ contractNature,
+ socialInsuranceStatus = input.SocialInsuranceStatus,
+ socialInsuranceCompany
+ };
+
+ await _db.Ado.BeginTranAsync();
+ try
+ {
+ await _db.Updateable()
+ .SetColumns(item => new ObEmployee
+ {
+ EmploymentType = input.EmploymentType,
+ RegularizationStatus = regularizationStatus,
+ RegularizationDate = regularizationDate,
+ ContractNature = contractNature,
+ SocialInsuranceStatus = input.SocialInsuranceStatus,
+ SocialInsuranceCompany = socialInsuranceCompany,
+ UpdateTime = DateTime.Now,
+ UpdateUserId = UserManager.UserId,
+ UpdateUser = UserManager.Name
+ })
+ .Where(item => item.Id == employee.Id)
+ .ExecuteCommandAsync();
+ await InsertChangeRecord(
+ employee.Id,
+ EmployeeChangeType.HrInfoUpdate,
+ null,
+ before,
+ after,
+ "编辑员工人事信息");
+ await _db.Ado.CommitTranAsync();
+ }
+ catch
+ {
+ await _db.Ado.RollbackTranAsync();
+ throw;
+ }
+ }
+
+ ///
+ public async Task Regularize(OnboardingEmployeeRegularizeInput input)
+ {
+ if (!input.RegularizationDate.HasValue)
+ throw Oops.Bah("请选择转正日期");
+
+ var employee = await _db.Queryable().InSingleAsync(input.Id)
+ ?? throw Oops.Bah("员工档案不存在");
+ if (employee.EmploymentType == EmployeeEmploymentType.PartTime)
+ throw Oops.Bah("兼职员工不适用转正流程,请先调整用工类型");
+ if (employee.RegularizationStatus == EmployeeRegularizationStatus.Regular)
+ throw Oops.Bah("该员工已经转正,请勿重复办理");
+
+ var regularizationDate = input.RegularizationDate.Value.Date;
+ if (employee.EntryDate.HasValue && regularizationDate < employee.EntryDate.Value.Date)
+ throw Oops.Bah("转正日期不能早于入职日期");
+
+ var fileIds = (input.FileIds ?? new List())
+ .Where(item => item > 0)
+ .Distinct()
+ .ToList();
+ var files = fileIds.Count == 0
+ ? new List()
+ : await _db.Queryable()
+ .Where(item => fileIds.Contains(item.Id))
+ .ToListAsync();
+ if (files.Count != fileIds.Count)
+ throw Oops.Bah("部分转正材料不存在或已被删除,请重新上传");
+
+ var before = CreateHrState(employee);
+ var after = new
+ {
+ employee.EmploymentType,
+ regularizationStatus = EmployeeRegularizationStatus.Regular,
+ regularizationDate,
+ employee.ContractNature,
+ employee.SocialInsuranceStatus,
+ employee.SocialInsuranceCompany
+ };
+
+ await _db.Ado.BeginTranAsync();
+ try
+ {
+ var changedRows = await _db.Updateable()
+ .SetColumns(item => new ObEmployee
+ {
+ RegularizationStatus = EmployeeRegularizationStatus.Regular,
+ RegularizationDate = regularizationDate,
+ UpdateTime = DateTime.Now,
+ UpdateUserId = UserManager.UserId,
+ UpdateUser = UserManager.Name
+ })
+ .Where(item => item.Id == employee.Id
+ && item.RegularizationStatus != EmployeeRegularizationStatus.Regular)
+ .ExecuteCommandAsync();
+ if (changedRows != 1)
+ throw Oops.Bah("该员工状态已发生变化,请刷新后重试");
+
+ var changeRecord = await InsertChangeRecord(
+ employee.Id,
+ EmployeeChangeType.Regularization,
+ regularizationDate,
+ before,
+ after,
+ NormalizeOptionalText(input.Remark));
+ if (files.Count > 0)
+ {
+ var now = DateTime.Now;
+ var attachments = files.Select(file => new ObEmployeeAttachment
+ {
+ Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
+ EmployeeId = employee.Id,
+ ChangeRecordId = changeRecord.Id,
+ BusinessType = EmployeeAttachmentBusinessType.Regularization,
+ FileId = file.Id,
+ FileName = file.Name,
+ UploadedAt = now,
+ Status = CommonStatusConst.ENABLE,
+ CreateTime = now,
+ CreateUserId = UserManager.UserId,
+ CreateUser = UserManager.Name
+ }).ToList();
+ await _db.Insertable(attachments).ExecuteCommandAsync();
+ }
+
+ await _db.Ado.CommitTranAsync();
+ }
+ catch
+ {
+ await _db.Ado.RollbackTranAsync();
+ throw;
+ }
+ }
+
+ ///
+ public async Task RevokeRegularization(OnboardingEmployeeRegularizationRevokeInput input)
+ {
+ if (string.IsNullOrWhiteSpace(input.Remark))
+ throw Oops.Bah("请填写撤销原因");
+
+ var employee = await _db.Queryable().InSingleAsync(input.Id)
+ ?? throw Oops.Bah("员工档案不存在");
+ if (employee.RegularizationStatus != EmployeeRegularizationStatus.Regular)
+ throw Oops.Bah("该员工当前不是已转正状态");
+
+ var before = CreateHrState(employee);
+ var after = new
+ {
+ employee.EmploymentType,
+ regularizationStatus = EmployeeRegularizationStatus.Probation,
+ regularizationDate = (DateTime?)null,
+ employee.ContractNature,
+ employee.SocialInsuranceStatus,
+ employee.SocialInsuranceCompany
+ };
+
+ await _db.Ado.BeginTranAsync();
+ try
+ {
+ var changedRows = await _db.Updateable()
+ .SetColumns(item => new ObEmployee
+ {
+ RegularizationStatus = EmployeeRegularizationStatus.Probation,
+ RegularizationDate = null,
+ UpdateTime = DateTime.Now,
+ UpdateUserId = UserManager.UserId,
+ UpdateUser = UserManager.Name
+ })
+ .Where(item => item.Id == employee.Id
+ && item.RegularizationStatus == EmployeeRegularizationStatus.Regular)
+ .ExecuteCommandAsync();
+ if (changedRows != 1)
+ throw Oops.Bah("该员工状态已发生变化,请刷新后重试");
+
+ await InsertChangeRecord(
+ employee.Id,
+ EmployeeChangeType.RegularizationRevoke,
+ DateTime.Now.Date,
+ before,
+ after,
+ input.Remark.Trim());
+ await _db.Ado.CommitTranAsync();
+ }
+ catch
+ {
+ await _db.Ado.RollbackTranAsync();
+ throw;
+ }
+ }
+
///
/// 查询员工来源任务中上传的全部材料,并补充原始文件名、后缀和文件大小。
///
@@ -141,6 +456,121 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
.ToList();
}
+ ///
+ /// 查询员工人事变更记录,并补充每条记录关联材料的文件信息。
+ ///
+ private async Task> GetEmployeeChangeRecords(long employeeId)
+ {
+ var records = await _db.Queryable()
+ .Where(item => item.EmployeeId == employeeId)
+ .OrderByDescending(item => item.CreateTime)
+ .OrderByDescending(item => item.Id)
+ .ToListAsync();
+ if (records.Count == 0)
+ return records;
+
+ var recordIds = records.Select(item => item.Id).ToList();
+ var attachments = await _db.Queryable()
+ .Where(item => item.EmployeeId == employeeId
+ && item.ChangeRecordId.HasValue
+ && recordIds.Contains(item.ChangeRecordId.Value))
+ .OrderBy(item => item.UploadedAt)
+ .ToListAsync();
+ if (attachments.Count > 0)
+ {
+ var fileIds = attachments.Select(item => item.FileId).Distinct().ToList();
+ var files = await _db.Queryable()
+ .Where(item => fileIds.Contains(item.Id))
+ .ToListAsync();
+ var fileMap = files.ToDictionary(item => item.Id);
+ foreach (var attachment in attachments)
+ {
+ if (!fileMap.TryGetValue(attachment.FileId, out var file))
+ continue;
+ attachment.FileName = string.IsNullOrWhiteSpace(attachment.FileName)
+ ? file.Name
+ : attachment.FileName;
+ attachment.FileSuffix = file.Suffix;
+ attachment.FileSizeInfo = file.SizeInfo;
+ }
+
+ var attachmentMap = attachments
+ .Where(item => item.ChangeRecordId.HasValue)
+ .GroupBy(item => item.ChangeRecordId.Value)
+ .ToDictionary(group => group.Key, group => group.ToList());
+ foreach (var record in records)
+ record.Attachments = attachmentMap.GetValueOrDefault(record.Id) ?? new List();
+ }
+
+ return records;
+ }
+
+ private async Task InsertChangeRecord(
+ long employeeId,
+ string changeType,
+ DateTime? effectiveDate,
+ object before,
+ object after,
+ string remark)
+ {
+ var now = DateTime.Now;
+ var record = new ObEmployeeChangeRecord
+ {
+ Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
+ EmployeeId = employeeId,
+ ChangeType = changeType,
+ EffectiveDate = effectiveDate,
+ BeforeJson = JsonSerializer.Serialize(before),
+ AfterJson = JsonSerializer.Serialize(after),
+ Remark = remark,
+ Status = CommonStatusConst.ENABLE,
+ CreateTime = now,
+ CreateUserId = UserManager.UserId,
+ CreateUser = UserManager.Name
+ };
+ await _db.Insertable(record).ExecuteCommandAsync();
+ return record;
+ }
+
+ private static object CreateHrState(ObEmployee employee)
+ {
+ return new
+ {
+ employee.EmploymentType,
+ employee.RegularizationStatus,
+ employee.RegularizationDate,
+ employee.ContractNature,
+ employee.SocialInsuranceStatus,
+ employee.SocialInsuranceCompany
+ };
+ }
+
+ private static string NormalizeOptionalText(string value)
+ {
+ return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+ }
+
+ private static void ValidateEmploymentType(string value)
+ {
+ if (value != EmployeeEmploymentType.FullTime
+ && value != EmployeeEmploymentType.PartTime)
+ {
+ throw Oops.Bah("用工类型不正确");
+ }
+ }
+
+ private static void ValidateSocialInsuranceStatus(string value)
+ {
+ if (value != EmployeeSocialInsuranceStatus.Unknown
+ && value != EmployeeSocialInsuranceStatus.NotPurchased
+ && value != EmployeeSocialInsuranceStatus.Purchased
+ && value != EmployeeSocialInsuranceStatus.Stopped
+ && value != EmployeeSocialInsuranceStatus.NotApplicable)
+ {
+ throw Oops.Bah("社保状态不正确");
+ }
+ }
+
///
/// 批量补充员工所属公司、部门和岗位名称,避免列表逐行查询数据库。
///
@@ -190,6 +620,571 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService
await FillMissingEntryDates(employeeList);
}
+ ///
+ /// 构造列表和导出共用的员工筛选,保证页面看到的范围与导出范围一致。
+ ///
+ private ISugarQueryable BuildEmployeeQuery(OnboardingEmployeePageInput input)
+ {
+ input ??= new OnboardingEmployeePageInput();
+ return _db.Queryable()
+ .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)
+ .WhereIF(
+ !string.IsNullOrWhiteSpace(input.EmploymentType),
+ item => item.EmploymentType == input.EmploymentType)
+ .WhereIF(
+ !string.IsNullOrWhiteSpace(input.RegularizationStatus),
+ item => item.RegularizationStatus == input.RegularizationStatus)
+ .WhereIF(
+ !string.IsNullOrWhiteSpace(input.SocialInsuranceStatus),
+ item => item.SocialInsuranceStatus == input.SocialInsuranceStatus);
+ }
+
+ private static void WriteEmployeeSheet(
+ ExcelWorksheet worksheet,
+ IReadOnlyList rows)
+ {
+ ExportColumn TextColumn(
+ string header,
+ Func value,
+ double? width = null) => CreateTextColumn(header, value, width);
+ ExportColumn DateColumn(
+ string header,
+ Func value,
+ string format = "yyyy-mm-dd") => CreateDateColumn(header, value, format);
+ ExportColumn NumberColumn(
+ string header,
+ Func value,
+ string format = "#,##0.00") => CreateNumberColumn(header, value, format);
+
+ var columns = new List>
+ {
+ TextColumn("姓名", row => row.Employee.EmployeeName),
+ TextColumn("所属公司", row => row.Employee.CompanyName, 22),
+ TextColumn("部门", row => row.Employee.DepartmentName),
+ TextColumn("岗位", row => row.Employee.PositionName),
+ TextColumn("用工类型", row => EmploymentTypeText(row.Employee.EmploymentType)),
+ TextColumn("性别", row => row.Employee.Gender),
+ TextColumn("手机号", row => row.Employee.Phone),
+ TextColumn("身份证号码", row => row.Employee.IdCardNumber),
+ TextColumn("院校", row => FirstNotBlank(row.Employee.College, row.EmployeeInfo?.GraduatedSchool)),
+ TextColumn("学历", row => FirstNotBlank(row.Employee.Education, row.EmployeeInfo?.Education)),
+ TextColumn("专业", row => FirstNotBlank(row.Employee.Major, row.EmployeeInfo?.Major)),
+ DateColumn("出生日期", row => row.Employee.BirthDate ?? ParseDate(row.EmployeeInfo?.Birthday)),
+ NumberColumn("年龄", row => CalculateAge(row.Employee.BirthDate ?? ParseDate(row.EmployeeInfo?.Birthday)), "0"),
+ TextColumn("户籍所在地", row => FirstNotBlank(row.Employee.RegisteredResidence, row.EmployeeInfo?.RegisteredResidence)),
+ TextColumn("民族", row => FirstNotBlank(row.Employee.Nation, row.EmployeeInfo?.Nation)),
+ TextColumn("通讯地址", row => FirstNotBlank(row.Employee.ContactAddress, row.EmployeeInfo?.CurrentAddress), 24),
+ DateColumn("入职时间", row => row.Employee.EntryDate),
+ TextColumn("司龄", row => FormatTenure(row.Employee.EntryDate)),
+ DateColumn("合同开始日期", row => row.PrimaryContract?.ContractStartDate ?? row.Employee.ContractStartDate),
+ DateColumn("合同终止日期", row => row.PrimaryContract?.ContractEndDate ?? row.Employee.ContractEndDate),
+ TextColumn("试用期", row => row.PrimaryContract?.ProbationTerm),
+ NumberColumn("试用薪资", row => row.PrimaryContract?.ProbationSalary),
+ DateColumn("转正日期", row => row.Employee.RegularizationDate),
+ TextColumn("是否转正", row => RegularizationText(row.Employee)),
+ NumberColumn("转正薪资", row => row.PrimaryContract?.RegularSalary),
+ TextColumn("是否购买社保", row => SocialInsuranceText(row.Employee.SocialInsuranceStatus)),
+ TextColumn("合同社保单位", row => row.Employee.SocialInsuranceCompany, 22),
+ TextColumn("合同性质", row => row.Employee.ContractNature),
+ TextColumn("工资卡账号", row => row.Employee.BankCardNumber),
+ TextColumn("工资卡开户行", row => row.Employee.BankName, 22)
+ };
+
+ WriteWorksheet(worksheet, columns, rows);
+ }
+
+ private static void WriteContractSheet(
+ ExcelWorksheet worksheet,
+ IReadOnlyList rows)
+ {
+ ExportColumn TextColumn(
+ string header,
+ Func value,
+ double? width = null) => CreateTextColumn(header, value, width);
+ ExportColumn DateColumn(
+ string header,
+ Func value,
+ string format = "yyyy-mm-dd") => CreateDateColumn(header, value, format);
+ ExportColumn NumberColumn(
+ string header,
+ Func value,
+ string format = "#,##0.00") => CreateNumberColumn(header, value, format);
+
+ var columns = new List>
+ {
+ TextColumn("姓名", row => row.Employee.EmployeeName),
+ TextColumn("员工编号", row => FirstNotBlank(row.Employee.EmployeeNo, row.Snapshot.EmployeeNo)),
+ TextColumn("所属公司", row => row.Employee.CompanyName, 22),
+ TextColumn("所属部门", row => row.Employee.DepartmentName),
+ TextColumn("所属岗位", row => row.Employee.PositionName),
+ TextColumn("手机号码", row => row.Employee.Phone),
+ TextColumn("身份证号码", row => row.Employee.IdCardNumber),
+ TextColumn("入职任务编号", row => row.TaskNo, 22),
+ TextColumn("合同名称", row => row.Snapshot.ContractName, 24),
+ TextColumn("合同类型", row => row.Snapshot.ContractType),
+ DateColumn("合同开始日期", row => row.Snapshot.ContractStartDate),
+ DateColumn("合同结束日期", row => row.Snapshot.ContractEndDate),
+ TextColumn("合同期限", row => row.Snapshot.ContractTerm),
+ DateColumn("试用期开始日期", row => row.Snapshot.ProbationStartDate),
+ DateColumn("试用期结束日期", row => row.Snapshot.ProbationEndDate),
+ TextColumn("试用期期限", row => row.Snapshot.ProbationTerm),
+ NumberColumn("试用薪资(元)", row => row.Snapshot.ProbationSalary),
+ NumberColumn("基本工资(元)", row => row.Snapshot.BaseSalary),
+ NumberColumn("岗位工资(元)", row => row.Snapshot.PositionSalary),
+ NumberColumn("绩效津贴(元)", row => row.Snapshot.PerformanceAllowance),
+ NumberColumn("全勤奖(元)", row => row.Snapshot.AttendanceBonus),
+ NumberColumn("转正后薪资(元)", row => row.Snapshot.RegularSalary),
+ TextColumn("工作地点", row => row.Snapshot.WorkCity),
+ TextColumn("合同岗位名称", row => row.Snapshot.PositionName),
+ DateColumn("快照生成时间", row => row.Snapshot.SnapshotAt, "yyyy-mm-dd hh:mm:ss")
+ };
+
+ var variableColumns = rows
+ .SelectMany(row => row.Variables.Values)
+ .GroupBy(item => item.Code, StringComparer.OrdinalIgnoreCase)
+ .Select(group => new
+ {
+ Code = group.Key,
+ Name = group.Select(item => item.Name).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)),
+ SortCode = group.Min(item => item.SortCode)
+ })
+ .OrderBy(item => item.SortCode)
+ .ThenBy(item => item.Code, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ foreach (var variable in variableColumns)
+ {
+ var code = variable.Code;
+ var header = string.IsNullOrWhiteSpace(variable.Name)
+ ? code
+ : $"{variable.Name}\n({code})";
+ columns.Add(TextColumn(
+ header,
+ row => row.Variables.TryGetValue(code, out var value) ? value.Value : null,
+ 24));
+ }
+
+ WriteWorksheet(worksheet, columns, rows);
+ }
+
+ private static void WriteWorksheet(
+ ExcelWorksheet worksheet,
+ IReadOnlyList> columns,
+ IReadOnlyList rows)
+ {
+ worksheet.View.ShowGridLines = false;
+ worksheet.View.FreezePanes(2, 1);
+
+ for (var columnIndex = 0; columnIndex < columns.Count; columnIndex++)
+ {
+ var column = columns[columnIndex];
+ var cell = worksheet.Cells[1, columnIndex + 1];
+ cell.Value = column.Header;
+ cell.Style.WrapText = true;
+ }
+
+ for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
+ {
+ var excelRow = rowIndex + 2;
+ for (var columnIndex = 0; columnIndex < columns.Count; columnIndex++)
+ {
+ var column = columns[columnIndex];
+ var cell = worksheet.Cells[excelRow, columnIndex + 1];
+ var value = column.Value(rows[rowIndex]);
+ if (value != null)
+ cell.Value = value;
+ if (!string.IsNullOrWhiteSpace(column.NumberFormat))
+ cell.Style.Numberformat.Format = column.NumberFormat;
+ cell.Style.HorizontalAlignment = column.Kind == ExportCellKind.Text
+ ? ExcelHorizontalAlignment.Left
+ : ExcelHorizontalAlignment.Right;
+ }
+
+ if (rowIndex % 2 == 1)
+ {
+ worksheet.Cells[excelRow, 1, excelRow, columns.Count].Style.Fill.PatternType = ExcelFillStyle.Solid;
+ worksheet.Cells[excelRow, 1, excelRow, columns.Count].Style.Fill.BackgroundColor.SetColor(
+ Color.FromRgb(247, 249, 252));
+ }
+ }
+
+ var lastRow = Math.Max(rows.Count + 1, 1);
+ var allCells = worksheet.Cells[1, 1, lastRow, columns.Count];
+ allCells.Style.Font.Name = "Microsoft YaHei";
+ allCells.Style.Font.Size = 10;
+ allCells.Style.VerticalAlignment = ExcelVerticalAlignment.Center;
+
+ var header = worksheet.Cells[1, 1, 1, columns.Count];
+ header.Style.Fill.PatternType = ExcelFillStyle.Solid;
+ header.Style.Fill.BackgroundColor.SetColor(Color.FromRgb(31, 78, 121));
+ header.Style.Font.Color.SetColor(Color.White);
+ header.Style.Font.Bold = true;
+ header.Style.HorizontalAlignment = ExcelHorizontalAlignment.Center;
+ header.Style.VerticalAlignment = ExcelVerticalAlignment.Center;
+ header.AutoFilter = true;
+ worksheet.Row(1).Height = 34;
+
+ if (rows.Count > 0)
+ {
+ var body = worksheet.Cells[2, 1, lastRow, columns.Count];
+ body.Style.VerticalAlignment = ExcelVerticalAlignment.Top;
+ body.Style.Border.Bottom.Style = ExcelBorderStyle.Hair;
+ body.Style.Border.Bottom.Color.SetColor(Color.FromRgb(224, 229, 236));
+ }
+
+ allCells.AutoFitColumns(10, 36);
+ for (var columnIndex = 0; columnIndex < columns.Count; columnIndex++)
+ {
+ var preferredWidth = columns[columnIndex].PreferredWidth;
+ if (preferredWidth.HasValue)
+ {
+ worksheet.Column(columnIndex + 1).Width = preferredWidth.Value;
+ if (rows.Count > 0)
+ worksheet.Cells[2, columnIndex + 1, lastRow, columnIndex + 1].Style.WrapText = true;
+ }
+ }
+ }
+
+ private static ExportColumn CreateTextColumn(
+ string header,
+ Func value,
+ double? preferredWidth = null)
+ {
+ return new ExportColumn(header, value, ExportCellKind.Text, "@", preferredWidth);
+ }
+
+ private static ExportColumn CreateDateColumn(
+ string header,
+ Func value,
+ string numberFormat = "yyyy-mm-dd")
+ {
+ return new ExportColumn(header, value, ExportCellKind.Date, numberFormat, 13);
+ }
+
+ private static ExportColumn CreateNumberColumn(
+ string header,
+ Func value,
+ string numberFormat = "#,##0.00")
+ {
+ return new ExportColumn(header, value, ExportCellKind.Number, numberFormat, 14);
+ }
+
+ private static ObEmployeeContractSnapshot SelectPrimaryContract(
+ IEnumerable snapshots)
+ {
+ return snapshots
+ .OrderByDescending(item => ContainsLaborContract(item.ContractName)
+ || ContainsLaborContract(item.ContractType))
+ .ThenByDescending(CountCommonContractValues)
+ .ThenBy(item => item.SnapshotAt)
+ .ThenBy(item => item.Id)
+ .FirstOrDefault();
+ }
+
+ private static bool ContainsLaborContract(string value)
+ {
+ return !string.IsNullOrWhiteSpace(value)
+ && value.Contains("劳动合同", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static int CountCommonContractValues(ObEmployeeContractSnapshot snapshot)
+ {
+ return new object[]
+ {
+ snapshot.ContractStartDate, snapshot.ContractEndDate, snapshot.ContractTerm,
+ snapshot.ProbationStartDate, snapshot.ProbationEndDate, snapshot.ProbationTerm,
+ snapshot.ProbationSalary, snapshot.BaseSalary, snapshot.PositionSalary,
+ snapshot.PerformanceAllowance, snapshot.AttendanceBonus, snapshot.RegularSalary,
+ snapshot.WorkCity, snapshot.PositionName, snapshot.EmployeeNo
+ }.Count(value => value != null && !string.IsNullOrWhiteSpace(value.ToString()));
+ }
+
+ private static ObTaskEmployeeInfo DeserializeEmployeeInfo(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return null;
+
+ try
+ {
+ return JsonSerializer.Deserialize(json);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static DateTime? ParseDate(string value)
+ {
+ return DateTime.TryParse(value, out var date) ? date.Date : null;
+ }
+
+ private static string FirstNotBlank(params string[] values)
+ {
+ return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
+ }
+
+ private static int? CalculateAge(DateTime? birthDate)
+ {
+ if (!birthDate.HasValue || birthDate.Value.Date > DateTime.Today)
+ return null;
+
+ var birthday = birthDate.Value.Date;
+ var age = DateTime.Today.Year - birthday.Year;
+ if (birthday.AddYears(age) > DateTime.Today)
+ age--;
+ return age < 0 ? null : age;
+ }
+
+ private static string FormatTenure(DateTime? entryDate)
+ {
+ if (!entryDate.HasValue || entryDate.Value.Date > DateTime.Today)
+ return null;
+
+ var start = entryDate.Value.Date;
+ var today = DateTime.Today;
+ var months = (today.Year - start.Year) * 12 + today.Month - start.Month;
+ if (today.Day < start.Day)
+ months--;
+ months = Math.Max(months, 0);
+
+ var years = months / 12;
+ var remainingMonths = months % 12;
+ if (years > 0 && remainingMonths > 0)
+ return $"{years}年{remainingMonths}个月";
+ if (years > 0)
+ return $"{years}年";
+ return $"{remainingMonths}个月";
+ }
+
+ private static string EmploymentTypeText(string status)
+ {
+ return status == EmployeeEmploymentType.PartTime ? "兼职" : "正式";
+ }
+
+ private static string RegularizationText(ObEmployee employee)
+ {
+ if (employee.EmploymentType == EmployeeEmploymentType.PartTime
+ || employee.RegularizationStatus == EmployeeRegularizationStatus.NotApplicable)
+ {
+ return "不适用";
+ }
+
+ if (employee.RegularizationStatus == EmployeeRegularizationStatus.Regular
+ || employee.RegularizationDate.HasValue)
+ {
+ return "是";
+ }
+
+ return "否";
+ }
+
+ private static string SocialInsuranceText(string status)
+ {
+ return status switch
+ {
+ EmployeeSocialInsuranceStatus.Purchased => "是",
+ EmployeeSocialInsuranceStatus.NotPurchased => "否",
+ EmployeeSocialInsuranceStatus.Stopped => "已停保",
+ EmployeeSocialInsuranceStatus.NotApplicable => "不适用",
+ _ => null
+ };
+ }
+
+ private static string FormatJsonRows(
+ string json,
+ params (string Key, string Label)[] fields)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return null;
+
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ if (document.RootElement.ValueKind != JsonValueKind.Array)
+ return json;
+
+ var rows = new List();
+ foreach (var item in document.RootElement.EnumerateArray())
+ {
+ if (item.ValueKind != JsonValueKind.Object)
+ continue;
+ var values = new List();
+ foreach (var field in fields)
+ {
+ if (!item.TryGetProperty(field.Key, out var property))
+ continue;
+ var value = JsonValueToString(property);
+ if (!string.IsNullOrWhiteSpace(value))
+ values.Add($"{field.Label}:{value}");
+ }
+ if (values.Count > 0)
+ rows.Add(string.Join(",", values));
+ }
+
+ return rows.Count == 0 ? null : string.Join(";", rows);
+ }
+ catch (JsonException)
+ {
+ return json;
+ }
+ }
+
+ private static string FormatJsonObject(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return null;
+
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ return json;
+ return string.Join(
+ ";",
+ document.RootElement.EnumerateObject()
+ .Select(property => $"{property.Name}:{JsonValueToString(property.Value)}"));
+ }
+ catch (JsonException)
+ {
+ return json;
+ }
+ }
+
+ private static Dictionary ParseContractVariables(string json)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (string.IsNullOrWhiteSpace(json))
+ return result;
+
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ return result;
+
+ foreach (var property in document.RootElement.EnumerateObject())
+ {
+ var item = property.Value;
+ if (item.ValueKind != JsonValueKind.Object)
+ continue;
+ result[property.Name] = new ContractVariableExportValue
+ {
+ Code = property.Name,
+ Name = TryGetJsonString(item, "variableName"),
+ Value = TryGetJsonString(item, "variableValue"),
+ SortCode = TryGetJsonInt(item, "sortCode")
+ };
+ }
+ }
+ catch (JsonException)
+ {
+ // 单份历史快照异常时保留其固定字段,跳过无法解析的动态变量。
+ }
+
+ return result;
+ }
+
+ private static string TryGetJsonString(JsonElement element, string propertyName)
+ {
+ return element.TryGetProperty(propertyName, out var property)
+ ? JsonValueToString(property)
+ : null;
+ }
+
+ private static int TryGetJsonInt(JsonElement element, string propertyName)
+ {
+ return element.TryGetProperty(propertyName, out var property)
+ && property.TryGetInt32(out var value)
+ ? value
+ : int.MaxValue;
+ }
+
+ private static string JsonValueToString(JsonElement element)
+ {
+ return element.ValueKind switch
+ {
+ JsonValueKind.String => element.GetString(),
+ JsonValueKind.Null => null,
+ JsonValueKind.Undefined => null,
+ _ => element.ToString()
+ };
+ }
+
+ private sealed class EmployeeExportContext
+ {
+ public ObEmployee Employee { get; set; }
+
+ public ObTaskEmployeeInfo EmployeeInfo { get; set; }
+
+ public ObEmployeeContractSnapshot PrimaryContract { get; set; }
+
+ public int ContractCount { get; set; }
+ }
+
+ private sealed class ContractExportContext
+ {
+ public ObEmployee Employee { get; set; }
+
+ public ObEmployeeContractSnapshot Snapshot { get; set; }
+
+ public string TaskNo { get; set; }
+
+ public Dictionary Variables { get; set; }
+ }
+
+ private sealed class ContractVariableExportValue
+ {
+ public string Code { get; set; }
+
+ public string Name { get; set; }
+
+ public string Value { get; set; }
+
+ public int SortCode { get; set; }
+ }
+
+ private sealed class ExportColumn
+ {
+ public ExportColumn(
+ string header,
+ Func value,
+ ExportCellKind kind,
+ string numberFormat,
+ double? preferredWidth)
+ {
+ Header = header;
+ Value = value;
+ Kind = kind;
+ NumberFormat = numberFormat;
+ PreferredWidth = preferredWidth;
+ }
+
+ public string Header { get; }
+
+ public Func Value { get; }
+
+ public ExportCellKind Kind { get; }
+
+ public string NumberFormat { get; }
+
+ public double? PreferredWidth { get; }
+ }
+
+ private enum ExportCellKind
+ {
+ Text,
+ Date,
+ Number
+ }
+
///
/// 兼容已建档但入职日期为空的历史员工,从来源任务的合同开始日期进行只读回填。
/// 新审核通过的员工会直接持久化该日期,不会进入此兼容分支。
diff --git a/SimpleAdmin.Application/Services/Onboarding/EmployeeContractSnapshot/IOnboardingEmployeeContractSnapshotService.cs b/SimpleAdmin.Application/Services/Onboarding/EmployeeContractSnapshot/IOnboardingEmployeeContractSnapshotService.cs
new file mode 100644
index 0000000..d199220
--- /dev/null
+++ b/SimpleAdmin.Application/Services/Onboarding/EmployeeContractSnapshot/IOnboardingEmployeeContractSnapshotService.cs
@@ -0,0 +1,10 @@
+namespace SimpleAdmin.Application;
+
+///
+/// 定义正式员工合同快照归档能力。
+///
+public interface IOnboardingEmployeeContractSnapshotService : ITransient
+{
+ /// 冻结一名员工来源任务中的全部合同和变量。
+ Task Build(long employeeId, long taskId);
+}
diff --git a/SimpleAdmin.Application/Services/Onboarding/EmployeeContractSnapshot/OnboardingEmployeeContractSnapshotService.cs b/SimpleAdmin.Application/Services/Onboarding/EmployeeContractSnapshot/OnboardingEmployeeContractSnapshotService.cs
new file mode 100644
index 0000000..53cea95
--- /dev/null
+++ b/SimpleAdmin.Application/Services/Onboarding/EmployeeContractSnapshot/OnboardingEmployeeContractSnapshotService.cs
@@ -0,0 +1,153 @@
+using System.Globalization;
+
+namespace SimpleAdmin.Application;
+
+///
+/// 在正式员工建档时,将每份任务合同及其全部变量冻结为长期业务快照。
+///
+public class OnboardingEmployeeContractSnapshotService : IOnboardingEmployeeContractSnapshotService
+{
+ private readonly SqlSugarScope _db;
+
+ public OnboardingEmployeeContractSnapshotService()
+ {
+ _db = DbContext.DB;
+ }
+
+ ///
+ public async Task Build(long employeeId, long taskId)
+ {
+ var contracts = await _db.Queryable()
+ .Where(item => item.TaskId == taskId)
+ .OrderBy(item => item.Id)
+ .ToListAsync();
+ if (contracts.Count == 0)
+ throw Oops.Bah("入职任务没有合同,无法生成员工合同快照");
+
+ var contractIds = contracts.Select(item => item.Id).ToList();
+ var allVariables = await _db.Queryable()
+ .Where(item => contractIds.Contains(item.TaskContractId))
+ .OrderBy(item => item.SortCode)
+ .OrderBy(item => item.Id)
+ .ToListAsync();
+ var now = DateTime.Now;
+ var snapshots = new List();
+
+ foreach (var contract in contracts)
+ {
+ var variables = allVariables
+ .Where(item => item.TaskContractId == contract.Id)
+ .OrderBy(item => item.SortCode)
+ .ThenBy(item => item.Id)
+ .ToList();
+ var valueMap = variables
+ .GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(
+ group => group.Key,
+ group => group.Last().VariableValue,
+ StringComparer.OrdinalIgnoreCase);
+ var variableSnapshot = variables
+ .GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(
+ group => group.Key,
+ group =>
+ {
+ // 历史任务若意外存在重复变量,以排序后的最后一条最终值为准,避免建档时报 500。
+ var item = group.Last();
+ return (object)new
+ {
+ variableName = item.VariableName,
+ variableType = item.VariableType,
+ dataType = item.DataType,
+ variableValue = item.VariableValue,
+ sourceType = item.SourceType,
+ isManualOverride = item.IsManualOverride,
+ sortCode = item.SortCode
+ };
+ },
+ StringComparer.OrdinalIgnoreCase);
+
+ snapshots.Add(new ObEmployeeContractSnapshot
+ {
+ Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
+ EmployeeId = employeeId,
+ TaskId = taskId,
+ TaskContractId = contract.Id,
+ TemplateId = contract.TemplateId,
+ TemplateVersionId = contract.TemplateVersionId,
+ ContractName = contract.ContractName,
+ ContractType = contract.ContractType,
+ ContractStartDate = ParseDate(GetValue(valueMap, "hr.ContractStartDate")),
+ ContractEndDate = ParseDate(GetValue(valueMap, "hr.ContractEndDate")),
+ ContractTerm = GetValue(valueMap, "hr.ContractTerm"),
+ ProbationStartDate = ParseDate(GetValue(valueMap, "hr.ProbationStartDate")),
+ ProbationEndDate = ParseDate(GetValue(valueMap, "hr.ProbationEndDate")),
+ ProbationTerm = GetValue(valueMap, "hr.ProbationTerm"),
+ ProbationSalary = ParseAmount(GetValue(valueMap, "hr.ProbationSalary")),
+ BaseSalary = ParseAmount(GetValue(valueMap, "hr.BaseSalary")),
+ PositionSalary = ParseAmount(GetValue(valueMap, "hr.PositionSalary")),
+ PerformanceAllowance = ParseAmount(GetValue(valueMap, "hr.PerformanceAllowance")),
+ AttendanceBonus = ParseAmount(GetValue(valueMap, "hr.AttendanceBonus")),
+ RegularSalary = ParseAmount(GetValue(valueMap, "hr.RegularSalary")),
+ WorkCity = GetValue(valueMap, "hr.WorkCity"),
+ PositionName = GetValue(valueMap, "hr.PositionName"),
+ EmployeeNo = GetValue(valueMap, "hr.EmployeeNo"),
+ VariablesJson = global::System.Text.Json.JsonSerializer.Serialize(variableSnapshot),
+ SnapshotVersion = 1,
+ SnapshotAt = now,
+ Status = CommonStatusConst.ENABLE,
+ CreateTime = now,
+ CreateUserId = UserManager.UserId,
+ CreateUser = UserManager.Name,
+ IsDelete = false
+ });
+ }
+
+ // Build 只在审核建档事务内执行;先删后建使异常重试保持幂等。
+ await _db.Deleteable()
+ .Where(item => item.TaskId == taskId)
+ .ExecuteCommandAsync();
+ await _db.Insertable(snapshots).ExecuteCommandAsync();
+ }
+
+ private static string GetValue(
+ IReadOnlyDictionary valueMap,
+ string variableCode)
+ {
+ return valueMap.TryGetValue(variableCode, out var value) && !string.IsNullOrWhiteSpace(value)
+ ? value.Trim()
+ : null;
+ }
+
+ private static DateTime? ParseDate(string value)
+ {
+ return DateTime.TryParse(value, out var date) ? date.Date : null;
+ }
+
+ private static decimal? ParseAmount(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return null;
+
+ var normalized = value
+ .Replace("¥", string.Empty)
+ .Replace("¥", string.Empty)
+ .Replace("元", string.Empty)
+ .Trim();
+ if (decimal.TryParse(
+ normalized,
+ NumberStyles.Number | NumberStyles.AllowCurrencySymbol,
+ CultureInfo.GetCultureInfo("zh-CN"),
+ out var amount)
+ || decimal.TryParse(
+ normalized,
+ NumberStyles.Number,
+ CultureInfo.InvariantCulture,
+ out amount))
+ {
+ return amount;
+ }
+
+ return null;
+ }
+}
diff --git a/SimpleAdmin.Application/Services/Onboarding/EntryConfig/OnboardingEntryConfigService.cs b/SimpleAdmin.Application/Services/Onboarding/EntryConfig/OnboardingEntryConfigService.cs
index dd67379..88a440c 100644
--- a/SimpleAdmin.Application/Services/Onboarding/EntryConfig/OnboardingEntryConfigService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/EntryConfig/OnboardingEntryConfigService.cs
@@ -27,6 +27,11 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
///
public async Task SaveFormField(OnboardingFormFieldSaveInput input)
{
+ input.FieldCode = OnboardingCodeValidator.Normalize(input.FieldCode, "字段编码", 100);
+ input.FieldName = input.FieldName?.Trim();
+ if (string.IsNullOrWhiteSpace(input.FieldName))
+ throw Oops.Bah("字段名称不能为空");
+
var fieldCodeQuery = _db.Queryable()
.Where(x => x.FieldCode == input.FieldCode);
if (input.Id.HasValue)
@@ -115,6 +120,10 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
///
public async Task SaveMaterialType(OnboardingMaterialTypeSaveInput input)
{
+ input.MaterialCode = OnboardingCodeValidator.Normalize(input.MaterialCode, "资料编码", 100);
+ input.MaterialName = input.MaterialName?.Trim();
+ if (string.IsNullOrWhiteSpace(input.MaterialName))
+ throw Oops.Bah("资料名称不能为空");
if (input.MaxFileCount <= 0)
throw Oops.Bah("最大文件数量必须大于0");
@@ -169,4 +178,4 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
await _db.Deleteable().In(input.Ids).ExecuteCommandAsync();
}
-}
\ No newline at end of file
+}
diff --git a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs
index 13b3007..35cc7a1 100644
--- a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs
@@ -92,12 +92,22 @@ public class OnboardingTemplateService : IOnboardingTemplateService
NormalizeAndValidateInput(input);
await ValidateReferences(input);
- var templateId = input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
- var codeExists = await _db.Queryable()
- .AnyAsync(item => item.TemplateCode == input.TemplateCode && item.Id != templateId);
- if (codeExists)
+ var codeOwner = await _db.Queryable()
+ .ClearFilter()
+ .FirstAsync(item => item.TemplateCode == input.TemplateCode
+ && (!input.Id.HasValue || item.Id != input.Id.Value));
+ if (codeOwner != null && !codeOwner.IsDelete)
throw Oops.Bah("入职模板编码已存在");
+ if (input.Id.HasValue && codeOwner != null && codeOwner.IsDelete)
+ throw Oops.Bah("该入职模板编码属于一条已删除记录,请通过新增模板重新启用该编码");
+
+ // 新增时如果编码命中历史软删除记录,复用原 Id 并恢复,避免唯一索引直接抛 500。
+ var isRestore = !input.Id.HasValue && codeOwner != null && codeOwner.IsDelete;
+ var templateId = isRestore
+ ? codeOwner.Id
+ : input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
+
var entity = new ObOnboardingTemplate
{
Id = templateId,
@@ -106,15 +116,17 @@ public class OnboardingTemplateService : IOnboardingTemplateService
CompanyId = input.CompanyId,
DepartmentId = input.DepartmentId,
Description = input.Description,
- Status = input.Status
+ Status = input.Status,
+ IsDelete = false
};
await _db.Ado.BeginTranAsync();
try
{
- if (input.Id.HasValue)
+ if (input.Id.HasValue || isRestore)
{
- var exists = await _db.Queryable().AnyAsync(item => item.Id == templateId);
+ var exists = isRestore || await _db.Queryable()
+ .AnyAsync(item => item.Id == templateId);
if (!exists)
throw Oops.Bah("入职模板不存在");
await _db.Updateable(entity).IgnoreColumns(ignoreAllNullColumns: true).ExecuteCommandAsync();
@@ -189,7 +201,10 @@ public class OnboardingTemplateService : IOnboardingTemplateService
private static void NormalizeAndValidateInput(OnboardingTemplateSaveInput input)
{
- input.TemplateCode = input.TemplateCode?.Trim();
+ input.TemplateCode = OnboardingCodeValidator.Normalize(
+ input.TemplateCode,
+ "入职模板编码",
+ 100);
input.TemplateName = input.TemplateName?.Trim();
input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List();
input.Contracts ??= new List();
@@ -208,8 +223,8 @@ public class OnboardingTemplateService : IOnboardingTemplateService
.Select(group => group.First())
.ToList() ?? new List();
- if (string.IsNullOrWhiteSpace(input.TemplateCode) || string.IsNullOrWhiteSpace(input.TemplateName))
- throw Oops.Bah("请填写入职模板编码和名称");
+ if (string.IsNullOrWhiteSpace(input.TemplateName))
+ throw Oops.Bah("请填写入职模板名称");
if (input.CompanyId <= 0)
throw Oops.Bah("请选择所属公司");
if (input.DepartmentId <= 0)
diff --git a/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs b/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs
index 1175cb3..1196d4b 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs
@@ -168,12 +168,11 @@ public class OnboardingPositionService : DbRepository, IOnboardingPo
///
private static void NormalizeInput(OnboardingPositionAddInput input)
{
- input.PositionCode = input.PositionCode?.Trim();
+ input.PositionCode = OnboardingCodeValidator.Normalize(input.PositionCode, "岗位编码", 50);
input.PositionName = input.PositionName?.Trim();
- if (string.IsNullOrWhiteSpace(input.PositionCode)
- || string.IsNullOrWhiteSpace(input.PositionName))
+ if (string.IsNullOrWhiteSpace(input.PositionName))
{
- throw Oops.Bah("请填写岗位编码和岗位名称");
+ throw Oops.Bah("请填写岗位名称");
}
if (input.Status != "enable" && input.Status != "disable")
diff --git a/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs b/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs
index ccb3938..3ad2b10 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs
@@ -25,6 +25,11 @@ public class OnboardingTaskCreateInput
[IdNotNull(ErrorMessage = "岗位Id不能为空")]
public long PositionId { get; set; }
+ ///
+ /// 用工类型:full_time 正式用工,part_time 兼职用工。
+ ///
+ public string EmploymentType { get; set; } = EmployeeEmploymentType.FullTime;
+
///
/// 本次任务使用的入职模板 Id。
///
diff --git a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs
index 6cc99df..3fb042e 100644
--- a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs
+++ b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs
@@ -26,6 +26,8 @@ public class OnboardingTaskService : IOnboardingTaskService
if (string.IsNullOrWhiteSpace(input.Remark))
throw Oops.Bah("请填写人事备注");
+ ValidateEmploymentType(input.EmploymentType);
+
var companyEnabled = await _db.Queryable()
.AnyAsync(item => item.Id == input.CompanyId && item.Status == "enable");
if (!companyEnabled)
@@ -123,6 +125,7 @@ public class OnboardingTaskService : IOnboardingTaskService
CompanyId = input.CompanyId,
DepartmentId = input.DepartmentId,
PositionId = input.PositionId,
+ EmploymentType = input.EmploymentType,
OnboardingTemplateId = onboardingTemplate.Id,
AssignedReviewerUserId = input.AssignedReviewerUserId,
LinkExpireAt = expiresAt,
@@ -366,6 +369,9 @@ public class OnboardingTaskService : IOnboardingTaskService
CompanyId = sourceTask.CompanyId,
DepartmentId = sourceTask.DepartmentId,
PositionId = sourceTask.PositionId,
+ EmploymentType = string.IsNullOrWhiteSpace(sourceTask.EmploymentType)
+ ? EmployeeEmploymentType.FullTime
+ : sourceTask.EmploymentType,
OnboardingTemplateId = sourceTask.OnboardingTemplateId,
AssignedReviewerUserId = sourceTask.AssignedReviewerUserId,
LinkExpireAt = expiresAt,
@@ -553,4 +559,13 @@ public class OnboardingTaskService : IOnboardingTaskService
if (endDate.Date < startDate.Date)
throw Oops.Bah($"{rangeName}结束日期不能早于开始日期");
}
+
+ private static void ValidateEmploymentType(string employmentType)
+ {
+ if (employmentType != EmployeeEmploymentType.FullTime
+ && employmentType != EmployeeEmploymentType.PartTime)
+ {
+ throw Oops.Bah("用工类型不正确,请选择正式员工或兼职员工");
+ }
+ }
}
diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingEmployeeController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingEmployeeController.cs
index 6607e33..c3a951c 100644
--- a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingEmployeeController.cs
+++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingEmployeeController.cs
@@ -2,37 +2,112 @@ using System.ComponentModel;
namespace SimpleAdmin.Web.Core;
///
-/// 提供正式员工档案相关 HTTP 接口。
+/// 提供员工档案及人事维护相关 HTTP 接口。
///
-[ApiDescriptionSettings("Application", Tag = "入职正式员工档案")]
+[ApiDescriptionSettings("Application", Tag = "入职员工档案")]
[Route("biz/onboarding/employee")]
[RolePermission]
public class OnboardingEmployeeController
{
private readonly IOnboardingEmployeeService _service;
+ private readonly IFileService _fileService;
- public OnboardingEmployeeController(IOnboardingEmployeeService service)
+ public OnboardingEmployeeController(
+ IOnboardingEmployeeService service,
+ IFileService fileService)
{
_service = service;
+ _fileService = fileService;
}
///
/// 分页查询正式员工档案。
///
[HttpGet("page")]
- [DisplayName("分页查询正式员工档案")]
+ [DisplayName("分页查询员工档案")]
public async Task Page([FromQuery] OnboardingEmployeePageInput input)
{
return await _service.Page(input);
}
+ ///
+ /// 按当前列表筛选条件导出员工基本信息和合同快照。
+ ///
+ [HttpPost("export")]
+ [DisplayName("导出员工明细")]
+ public async Task Export([FromBody] OnboardingEmployeePageInput input)
+ {
+ var file = await _service.Export(input);
+ return new FileContentResult(
+ file.FileBytes,
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+ {
+ FileDownloadName = file.FileName
+ };
+ }
+
///
/// 查询正式员工详情及来源记录。
///
[HttpGet("detail")]
- [DisplayName("查询正式员工详情")]
+ [DisplayName("查询员工档案详情")]
public async Task Detail([FromQuery] BaseIdInput input)
{
return await _service.Detail(input);
}
+
+ ///
+ /// 编辑员工用工、社保及合同性质等人事信息。
+ ///
+ [HttpPost("hr-info/update")]
+ [DisplayName("编辑员工人事信息")]
+ public async Task UpdateHrInfo([FromBody] OnboardingEmployeeHrUpdateInput input)
+ {
+ await _service.UpdateHrInfo(input);
+ }
+
+ ///
+ /// 上传转正等员工人事材料。
+ ///
+ [HttpPost("attachment/upload")]
+ [Consumes("multipart/form-data")]
+ [RequestSizeLimit(20 * 1024 * 1024)]
+ [DisplayName("上传员工人事材料")]
+ public async Task UploadAttachment([FromForm] IFormFile file)
+ {
+ if (file == null || file.Length == 0)
+ throw Oops.Bah("请选择需要上传的材料文件");
+
+ var allowedExtensions = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx",
+ ".jpg", ".jpeg", ".png", ".webp", ".zip"
+ };
+ var extension = Path.GetExtension(file.FileName);
+ if (!allowedExtensions.Contains(extension))
+ throw Oops.Bah("仅支持 PDF、Word、Excel、图片或 ZIP 文件");
+
+ return await _fileService.UploadFile(SysDictConst.FILE_ENGINE_ALIYUN_OSS, file);
+ }
+
+ ///
+ /// 人工办理员工转正。
+ ///
+ [HttpPost("regularization/confirm")]
+ [DisplayName("办理员工转正")]
+ public async Task Regularize([FromBody] OnboardingEmployeeRegularizeInput input)
+ {
+ await _service.Regularize(input);
+ }
+
+ ///
+ /// 撤销员工转正。
+ ///
+ [HttpPost("regularization/revoke")]
+ [DisplayName("撤销员工转正")]
+ public async Task RevokeRegularization(
+ [FromBody] OnboardingEmployeeRegularizationRevokeInput input)
+ {
+ await _service.RevokeRegularization(input);
+ }
}
diff --git a/database/migrations/20260908_employee_contract_snapshot.sql b/database/migrations/20260908_employee_contract_snapshot.sql
new file mode 100644
index 0000000..b34935d
--- /dev/null
+++ b/database/migrations/20260908_employee_contract_snapshot.sql
@@ -0,0 +1,163 @@
+-- 员工合同快照:一名正式员工的一份任务合同对应一条记录。
+-- 常用字段独立存储用于筛选导出,VariablesJson 按 VariableCode 保存全部标准及自定义变量。
+-- 适用数据库:MySQL 8.0+
+
+SET NAMES utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `ob_employee_contract_snapshot` (
+ `Id` BIGINT NOT NULL COMMENT '主键',
+ `EmployeeId` BIGINT NOT NULL COMMENT '正式员工Id',
+ `TaskId` BIGINT NOT NULL COMMENT '来源入职任务Id',
+ `TaskContractId` BIGINT NOT NULL COMMENT '来源任务合同Id',
+ `TemplateId` BIGINT NOT NULL COMMENT '合同模板Id',
+ `TemplateVersionId` BIGINT NOT NULL COMMENT '合同模板版本Id',
+ `ContractName` VARCHAR(300) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '合同名称快照',
+ `ContractType` VARCHAR(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合同类型快照',
+ `ContractStartDate` DATE DEFAULT NULL COMMENT '合同开始日期',
+ `ContractEndDate` DATE DEFAULT NULL COMMENT '合同结束日期',
+ `ContractTerm` VARCHAR(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合同期限原始文本',
+ `ProbationStartDate` DATE DEFAULT NULL COMMENT '试用期开始日期',
+ `ProbationEndDate` DATE DEFAULT NULL COMMENT '试用期结束日期',
+ `ProbationTerm` VARCHAR(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '试用期期限原始文本',
+ `ProbationSalary` DECIMAL(18,2) DEFAULT NULL COMMENT '试用薪资,对应hr.ProbationSalary,不自动推算',
+ `BaseSalary` DECIMAL(18,2) DEFAULT NULL COMMENT '基本工资',
+ `PositionSalary` DECIMAL(18,2) DEFAULT NULL COMMENT '岗位工资',
+ `PerformanceAllowance` DECIMAL(18,2) DEFAULT NULL COMMENT '绩效津贴',
+ `AttendanceBonus` DECIMAL(18,2) DEFAULT NULL COMMENT '全勤奖',
+ `RegularSalary` DECIMAL(18,2) DEFAULT NULL COMMENT '转正后薪资',
+ `WorkCity` VARCHAR(300) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '工作地点或城市',
+ `PositionName` VARCHAR(300) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合同岗位名称',
+ `EmployeeNo` VARCHAR(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合同员工工号',
+ `VariablesJson` JSON NOT NULL COMMENT '按VariableCode索引的完整合同变量快照',
+ `SnapshotVersion` INT NOT NULL DEFAULT 1 COMMENT '快照结构版本',
+ `SnapshotAt` DATETIME(3) NOT NULL COMMENT '快照生成时间',
+ `ExtJson` LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '扩展信息',
+ `Status` VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT 'enable' COMMENT '记录状态',
+ `CreateTime` DATETIME(3) DEFAULT NULL COMMENT '创建时间',
+ `UpdateTime` DATETIME(3) DEFAULT NULL COMMENT '更新时间',
+ `CreateUserId` BIGINT DEFAULT NULL COMMENT '创建者Id',
+ `UpdateUserId` BIGINT DEFAULT NULL COMMENT '修改者Id',
+ `CreateUser` VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人',
+ `UpdateUser` VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人',
+ `IsDelete` TINYINT(1) DEFAULT 0 COMMENT '软删除',
+ PRIMARY KEY (`Id`),
+ UNIQUE KEY `uk_ob_employee_contract_snapshot_contract` (`TaskContractId`),
+ KEY `idx_ob_employee_contract_snapshot_employee` (`EmployeeId`, `IsDelete`),
+ KEY `idx_ob_employee_contract_snapshot_task` (`TaskId`, `IsDelete`),
+ KEY `idx_ob_employee_contract_snapshot_dates` (`ContractStartDate`, `ContractEndDate`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='正式员工合同业务快照';
+
+-- 为已经审核建档且仍保留任务合同变量的员工补建快照。
+INSERT IGNORE INTO `ob_employee_contract_snapshot` (
+ `Id`, `EmployeeId`, `TaskId`, `TaskContractId`, `TemplateId`, `TemplateVersionId`,
+ `ContractName`, `ContractType`, `ContractStartDate`, `ContractEndDate`, `ContractTerm`,
+ `ProbationStartDate`, `ProbationEndDate`, `ProbationTerm`, `ProbationSalary`,
+ `BaseSalary`, `PositionSalary`, `PerformanceAllowance`, `AttendanceBonus`, `RegularSalary`,
+ `WorkCity`, `PositionName`, `EmployeeNo`, `VariablesJson`, `SnapshotVersion`, `SnapshotAt`,
+ `Status`, `CreateTime`, `CreateUserId`, `CreateUser`, `IsDelete`
+)
+SELECT
+ contract.`Id`,
+ task.`EmployeeId`,
+ task.`Id`,
+ contract.`Id`,
+ contract.`TemplateId`,
+ contract.`TemplateVersionId`,
+ contract.`ContractName`,
+ contract.`ContractType`,
+ CASE
+ WHEN TRIM(variable.`ContractStartDateText`) REGEXP '^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$'
+ THEN STR_TO_DATE(TRIM(variable.`ContractStartDateText`), '%Y-%m-%d')
+ ELSE NULL
+ END,
+ CASE
+ WHEN TRIM(variable.`ContractEndDateText`) REGEXP '^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$'
+ THEN STR_TO_DATE(TRIM(variable.`ContractEndDateText`), '%Y-%m-%d')
+ ELSE NULL
+ END,
+ variable.`ContractTerm`,
+ CASE
+ WHEN TRIM(variable.`ProbationStartDateText`) REGEXP '^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$'
+ THEN STR_TO_DATE(TRIM(variable.`ProbationStartDateText`), '%Y-%m-%d')
+ ELSE NULL
+ END,
+ CASE
+ WHEN TRIM(variable.`ProbationEndDateText`) REGEXP '^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$'
+ THEN STR_TO_DATE(TRIM(variable.`ProbationEndDateText`), '%Y-%m-%d')
+ ELSE NULL
+ END,
+ variable.`ProbationTerm`,
+ CASE WHEN TRIM(variable.`ProbationSalaryText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
+ THEN CAST(TRIM(variable.`ProbationSalaryText`) AS DECIMAL(18,2)) ELSE NULL END,
+ CASE WHEN TRIM(variable.`BaseSalaryText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
+ THEN CAST(TRIM(variable.`BaseSalaryText`) AS DECIMAL(18,2)) ELSE NULL END,
+ CASE WHEN TRIM(variable.`PositionSalaryText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
+ THEN CAST(TRIM(variable.`PositionSalaryText`) AS DECIMAL(18,2)) ELSE NULL END,
+ CASE WHEN TRIM(variable.`PerformanceAllowanceText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
+ THEN CAST(TRIM(variable.`PerformanceAllowanceText`) AS DECIMAL(18,2)) ELSE NULL END,
+ CASE WHEN TRIM(variable.`AttendanceBonusText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
+ THEN CAST(TRIM(variable.`AttendanceBonusText`) AS DECIMAL(18,2)) ELSE NULL END,
+ CASE WHEN TRIM(variable.`RegularSalaryText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
+ THEN CAST(TRIM(variable.`RegularSalaryText`) AS DECIMAL(18,2)) ELSE NULL END,
+ variable.`WorkCity`,
+ variable.`PositionName`,
+ variable.`EmployeeNo`,
+ COALESCE(variable.`VariablesJson`, JSON_OBJECT()),
+ 1,
+ COALESCE(task.`AuditedAt`, employee.`CreateTime`, NOW(3)),
+ 'enable',
+ COALESCE(employee.`CreateTime`, NOW(3)),
+ employee.`CreateUserId`,
+ employee.`CreateUser`,
+ 0
+FROM `ob_onboarding_task` task
+INNER JOIN `ob_employee` employee
+ ON employee.`Id` = task.`EmployeeId`
+INNER JOIN `ob_task_contract` contract
+ ON contract.`TaskId` = task.`Id`
+LEFT JOIN (
+ SELECT
+ row_value.`TaskContractId`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.ContractStartDate' THEN row_value.`VariableValue` END) AS `ContractStartDateText`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.ContractEndDate' THEN row_value.`VariableValue` END) AS `ContractEndDateText`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.ContractTerm' THEN row_value.`VariableValue` END) AS `ContractTerm`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.ProbationStartDate' THEN row_value.`VariableValue` END) AS `ProbationStartDateText`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.ProbationEndDate' THEN row_value.`VariableValue` END) AS `ProbationEndDateText`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.ProbationTerm' THEN row_value.`VariableValue` END) AS `ProbationTerm`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.ProbationSalary' THEN row_value.`VariableValue` END) AS `ProbationSalaryText`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.BaseSalary' THEN row_value.`VariableValue` END) AS `BaseSalaryText`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.PositionSalary' THEN row_value.`VariableValue` END) AS `PositionSalaryText`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.PerformanceAllowance' THEN row_value.`VariableValue` END) AS `PerformanceAllowanceText`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.AttendanceBonus' THEN row_value.`VariableValue` END) AS `AttendanceBonusText`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.RegularSalary' THEN row_value.`VariableValue` END) AS `RegularSalaryText`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.WorkCity' THEN row_value.`VariableValue` END) AS `WorkCity`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.PositionName' THEN row_value.`VariableValue` END) AS `PositionName`,
+ MAX(CASE WHEN row_value.`VariableCode` = 'hr.EmployeeNo' THEN row_value.`VariableValue` END) AS `EmployeeNo`,
+ JSON_OBJECTAGG(
+ row_value.`VariableCode`,
+ JSON_OBJECT(
+ 'variableName', row_value.`VariableName`,
+ 'variableType', row_value.`VariableType`,
+ 'dataType', row_value.`DataType`,
+ 'variableValue', row_value.`VariableValue`,
+ 'sourceType', row_value.`SourceType`,
+ 'isManualOverride', row_value.`IsManualOverride`,
+ 'sortCode', row_value.`SortCode`
+ )
+ ) AS `VariablesJson`
+ FROM `ob_task_contract_variable` row_value
+ WHERE row_value.`IsDelete` = 0
+ GROUP BY row_value.`TaskContractId`
+) variable
+ ON variable.`TaskContractId` = contract.`Id`
+WHERE task.`EmployeeId` IS NOT NULL
+ AND task.`IsDelete` = 0
+ AND employee.`IsDelete` = 0
+ AND contract.`IsDelete` = 0;
+
+SELECT
+ COUNT(*) AS `员工合同快照数量`,
+ COUNT(DISTINCT `EmployeeId`) AS `已归档员工数量`,
+ COUNT(DISTINCT `TaskContractId`) AS `已归档合同数量`
+FROM `ob_employee_contract_snapshot`
+WHERE `IsDelete` = 0;
diff --git a/database/migrations/20260909_employee_hr_management.sql b/database/migrations/20260909_employee_hr_management.sql
new file mode 100644
index 0000000..1389ce3
--- /dev/null
+++ b/database/migrations/20260909_employee_hr_management.sql
@@ -0,0 +1,172 @@
+-- 员工用工类型、人工转正、社保状态及人事变更材料。
+-- 适用数据库:MySQL 8.0+
+
+SET NAMES utf8mb4;
+
+DROP PROCEDURE IF EXISTS ob_upgrade_employee_hr_management;
+DELIMITER $$
+
+CREATE PROCEDURE ob_upgrade_employee_hr_management()
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'ob_onboarding_task'
+ AND COLUMN_NAME = 'EmploymentType'
+ ) THEN
+ ALTER TABLE ob_onboarding_task
+ ADD COLUMN EmploymentType VARCHAR(20) COLLATE utf8mb4_unicode_ci
+ NOT NULL DEFAULT 'full_time' COMMENT '用工类型:full_time正式、part_time兼职'
+ AFTER PositionId;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'ob_employee'
+ AND COLUMN_NAME = 'EmploymentType'
+ ) THEN
+ ALTER TABLE ob_employee
+ ADD COLUMN EmploymentType VARCHAR(20) COLLATE utf8mb4_unicode_ci
+ NOT NULL DEFAULT 'full_time' COMMENT '用工类型:full_time正式、part_time兼职'
+ AFTER PositionId;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'ob_employee'
+ AND COLUMN_NAME = 'RegularizationStatus'
+ ) THEN
+ ALTER TABLE ob_employee
+ ADD COLUMN RegularizationStatus VARCHAR(20) COLLATE utf8mb4_unicode_ci
+ NOT NULL DEFAULT 'probation' COMMENT '转正状态:probation、regular、not_applicable'
+ AFTER EmploymentType;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'ob_employee'
+ AND COLUMN_NAME = 'SocialInsuranceStatus'
+ ) THEN
+ ALTER TABLE ob_employee
+ ADD COLUMN SocialInsuranceStatus VARCHAR(20) COLLATE utf8mb4_unicode_ci
+ NOT NULL DEFAULT 'unknown' COMMENT '社保状态:unknown、not_purchased、purchased、stopped、not_applicable'
+ AFTER RegularizationStatus;
+ END IF;
+
+ IF EXISTS (
+ SELECT 1 FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'ob_employee_contract_snapshot'
+ ) AND NOT EXISTS (
+ SELECT 1 FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'ob_employee_contract_snapshot'
+ AND COLUMN_NAME = 'ProbationSalary'
+ ) THEN
+ ALTER TABLE ob_employee_contract_snapshot
+ ADD COLUMN ProbationSalary DECIMAL(18,2) DEFAULT NULL
+ COMMENT '试用薪资,对应hr.ProbationSalary,不自动推算'
+ AFTER ProbationTerm;
+ END IF;
+END$$
+
+DELIMITER ;
+CALL ob_upgrade_employee_hr_management();
+DROP PROCEDURE IF EXISTS ob_upgrade_employee_hr_management;
+
+-- 历史任务及员工默认按正式用工处理;历史转正日期仅用于一次性初始化状态。
+UPDATE ob_onboarding_task
+SET EmploymentType = 'full_time'
+WHERE EmploymentType IS NULL OR EmploymentType NOT IN ('full_time', 'part_time');
+
+UPDATE ob_employee
+SET
+ EmploymentType = CASE
+ WHEN EmploymentType = 'part_time' THEN 'part_time'
+ ELSE 'full_time'
+ END,
+ RegularizationStatus = CASE
+ WHEN EmploymentType = 'part_time' THEN 'not_applicable'
+ WHEN RegularizationDate IS NOT NULL THEN 'regular'
+ ELSE 'probation'
+ END,
+ SocialInsuranceStatus = CASE
+ WHEN SocialInsuranceStatus IN (
+ 'unknown', 'not_purchased', 'purchased', 'stopped', 'not_applicable'
+ ) THEN SocialInsuranceStatus
+ ELSE 'unknown'
+ END;
+
+CREATE TABLE IF NOT EXISTS ob_employee_change_record (
+ Id BIGINT NOT NULL COMMENT '主键',
+ EmployeeId BIGINT NOT NULL COMMENT '员工Id',
+ ChangeType VARCHAR(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更类型',
+ EffectiveDate DATE DEFAULT NULL COMMENT '业务生效日期',
+ BeforeJson LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '变更前快照',
+ AfterJson LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '变更后快照',
+ Remark VARCHAR(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
+ ExtJson LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '扩展信息',
+ Status VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT 'enable' COMMENT '记录状态',
+ CreateTime DATETIME(3) DEFAULT NULL COMMENT '创建时间',
+ UpdateTime DATETIME(3) DEFAULT NULL COMMENT '更新时间',
+ CreateUserId BIGINT DEFAULT NULL COMMENT '创建者Id',
+ UpdateUserId BIGINT DEFAULT NULL COMMENT '修改者Id',
+ CreateUser VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人',
+ UpdateUser VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人',
+ IsDelete TINYINT(1) DEFAULT 0 COMMENT '软删除',
+ PRIMARY KEY (Id),
+ KEY idx_ob_employee_change_record_employee (EmployeeId, IsDelete, CreateTime),
+ KEY idx_ob_employee_change_record_type (ChangeType, EffectiveDate)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工人事信息变更记录';
+
+CREATE TABLE IF NOT EXISTS ob_employee_attachment (
+ Id BIGINT NOT NULL COMMENT '主键',
+ EmployeeId BIGINT NOT NULL COMMENT '员工Id',
+ ChangeRecordId BIGINT DEFAULT NULL COMMENT '关联人事变更记录Id',
+ BusinessType VARCHAR(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '附件业务类型',
+ FileId BIGINT NOT NULL COMMENT '系统文件Id',
+ FileName VARCHAR(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上传时文件名快照',
+ UploadedAt DATETIME(3) NOT NULL COMMENT '上传关联时间',
+ ExtJson LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '扩展信息',
+ Status VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT 'enable' COMMENT '记录状态',
+ CreateTime DATETIME(3) DEFAULT NULL COMMENT '创建时间',
+ UpdateTime DATETIME(3) DEFAULT NULL COMMENT '更新时间',
+ CreateUserId BIGINT DEFAULT NULL COMMENT '创建者Id',
+ UpdateUserId BIGINT DEFAULT NULL COMMENT '修改者Id',
+ CreateUser VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人',
+ UpdateUser VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人',
+ IsDelete TINYINT(1) DEFAULT 0 COMMENT '软删除',
+ PRIMARY KEY (Id),
+ KEY idx_ob_employee_attachment_employee (EmployeeId, BusinessType, IsDelete),
+ KEY idx_ob_employee_attachment_change (ChangeRecordId, IsDelete),
+ KEY idx_ob_employee_attachment_file (FileId)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工人事材料附件';
+
+-- 标准试用薪资变量;已有同编码历史数据时恢复并统一为内置数值变量。
+INSERT INTO ob_variable_definition (
+ Id, VariableCode, VariableName, VariableCategory, DataType,
+ IsBuiltIn, Required, SortCode, Remark, Status,
+ CreateTime, IsDelete
+) VALUES (
+ 841400000000115, 'hr.ProbationSalary', '试用薪资(元)', 'hr', 'number',
+ 1, 0, 335, '合同约定试用期薪资,不自动推算', 'enable',
+ NOW(), 0
+)
+ON DUPLICATE KEY UPDATE
+ VariableName = VALUES(VariableName),
+ VariableCategory = 'hr',
+ DataType = 'number',
+ IsBuiltIn = 1,
+ SortCode = 335,
+ Remark = VALUES(Remark),
+ Status = 'enable',
+ IsDelete = 0;
+
+SELECT
+ (SELECT COUNT(*) FROM ob_employee_change_record) AS '员工人事变更记录数',
+ (SELECT COUNT(*) FROM ob_employee_attachment) AS '员工人事附件数',
+ (SELECT COUNT(*) FROM ob_variable_definition
+ WHERE VariableCode = 'hr.ProbationSalary' AND IsDelete = 0) AS '试用薪资标准变量数';
diff --git a/database/scripts/20260828_reset_onboarding_business_data.sql b/database/scripts/20260828_reset_onboarding_business_data.sql
index f63715a..e203356 100644
--- a/database/scripts/20260828_reset_onboarding_business_data.sql
+++ b/database/scripts/20260828_reset_onboarding_business_data.sql
@@ -49,6 +49,35 @@ BEGIN
START TRANSACTION;
-- 一、删除任务运行数据及快照。
+ -- 兼容尚未执行员工人事管理迁移的旧数据库。
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'ob_employee_attachment'
+ ) THEN
+ DELETE FROM ob_employee_attachment;
+ END IF;
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'ob_employee_change_record'
+ ) THEN
+ DELETE FROM ob_employee_change_record;
+ END IF;
+ -- 兼容尚未执行员工合同快照迁移的旧数据库。
+ IF EXISTS (
+ SELECT 1
+ FROM `information_schema`.`TABLES`
+ WHERE `TABLE_SCHEMA` = DATABASE()
+ AND `TABLE_NAME` = 'ob_employee_contract_snapshot'
+ ) THEN
+ SET @delete_employee_contract_snapshot_sql = 'DELETE FROM `ob_employee_contract_snapshot`';
+ PREPARE delete_employee_contract_snapshot_statement FROM @delete_employee_contract_snapshot_sql;
+ EXECUTE delete_employee_contract_snapshot_statement;
+ DEALLOCATE PREPARE delete_employee_contract_snapshot_statement;
+ END IF;
DELETE FROM `ob_task_operation_log`;
DELETE FROM `ob_task_audit`;
DELETE FROM `ob_task_material`;
diff --git a/database/scripts/20260908_reset_onboarding_personnel_and_sign_data.sql b/database/scripts/20260908_reset_onboarding_personnel_and_sign_data.sql
index 74eb1d1..7c37dda 100644
--- a/database/scripts/20260908_reset_onboarding_personnel_and_sign_data.sql
+++ b/database/scripts/20260908_reset_onboarding_personnel_and_sign_data.sql
@@ -43,6 +43,9 @@ BEGIN
'ob_electronic_sign_event',
'ob_electronic_sign_document',
'ob_electronic_sign_process',
+ 'ob_employee_attachment',
+ 'ob_employee_change_record',
+ 'ob_employee_contract_snapshot',
'ob_task_operation_log',
'ob_task_audit',
'ob_task_material',
@@ -61,6 +64,9 @@ BEGIN
'ob_electronic_sign_event',
'ob_electronic_sign_document',
'ob_electronic_sign_process',
+ 'ob_employee_attachment',
+ 'ob_employee_change_record',
+ 'ob_employee_contract_snapshot',
'ob_task_operation_log',
'ob_task_audit',
'ob_task_material',
@@ -166,6 +172,9 @@ BEGIN
'ob_electronic_sign_event',
'ob_electronic_sign_document',
'ob_electronic_sign_process',
+ 'ob_employee_attachment',
+ 'ob_employee_change_record',
+ 'ob_employee_contract_snapshot',
'ob_task_operation_log',
'ob_task_audit',
'ob_task_material',
@@ -199,6 +208,9 @@ WHERE `TABLE_SCHEMA` = DATABASE()
'ob_electronic_sign_event',
'ob_electronic_sign_document',
'ob_electronic_sign_process',
+ 'ob_employee_attachment',
+ 'ob_employee_change_record',
+ 'ob_employee_contract_snapshot',
'ob_task_operation_log',
'ob_task_audit',
'ob_task_material',