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',