feat: 20260815 系统优化 - 全部31项问题修复(P0×6+P1×14+P2×9+P3×2)
P0紧急修复(6项): - 草稿保存完整恢复所有字段(含socialAvgWage) - 补偿金批次从compensationBreakdown读取 - 违法解除风险确认UI - 合同结束日期前后校验(前后端双保险) P1高优先级(14项): - 离职日期联动社保/公积金截止月(15号规则) - 合规检查+工作交接改为软阻断(生成待办) - 补偿月数(N/N+1/2N/自定义)+计算基数(近12月/合同/自定义) - 解聘并入花名册操作栏(类型选择跳转向导) - 合同续签开始日期自动推导(原合同结束日+1天) - 年龄合规筛查(童工阻断/未成年工/退休警告) - 编辑入职日期后状态联动(待入职↔在职) - 转正移植到花名册操作栏+薪资回写 - 男职工无法选择三期 P2体验优化(9项): - "劳动合同"调整为"用工关系" - 费用结算新增剩余年假折算(300%日工资) - 身份证号全域改为"证件号码"(前后端18个文件) - 手机号查重 - 开具证明+合同续签移植到花名册操作栏 - 批量转正+批量开具证明 - 去掉用工办理模块 P3规划(2项): - 组织架构+审批流(Department/Position/ApprovalFlow/ApprovalInstance) - 客服工作台(Ticket/ChatSession+SUPPORT角色) 新增模型: Department/Position/ApprovalFlow/ApprovalInstance/Ticket/TicketMessage/ChatSession/ChatMessage 新增字段: Employee.departmentId/supervisorId 新增角色: SUPPORT Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 审批流引擎
|
||||
* 支持最多 3 步审批(发起人 → 直属上级 → 部门负责人)
|
||||
*/
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
interface ApprovalStep {
|
||||
step: number
|
||||
approverType: 'SUPERVISOR' | 'DEPT_HEAD' | 'PERSON'
|
||||
approverId?: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ApprovalRecord {
|
||||
step: number
|
||||
approverId: string
|
||||
approverName: string
|
||||
result: 'APPROVED' | 'REJECTED'
|
||||
comment?: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建审批实例
|
||||
*/
|
||||
export async function createApprovalInstance(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
type: string,
|
||||
bizId: string,
|
||||
bizType: string,
|
||||
employeeId: string,
|
||||
): Promise<{ instance: any; firstApprover?: any }> {
|
||||
// 查找该类型的审批流配置
|
||||
const flow = await prisma.approvalFlow.findFirst({
|
||||
where: { orgId, type, enabled: true },
|
||||
})
|
||||
if (!flow) {
|
||||
// 无审批流配置,直接通过
|
||||
return { instance: null }
|
||||
}
|
||||
|
||||
const steps = flow.steps as unknown as ApprovalStep[]
|
||||
if (!steps || steps.length === 0) {
|
||||
return { instance: null }
|
||||
}
|
||||
|
||||
const instance = await prisma.approvalInstance.create({
|
||||
data: {
|
||||
orgId,
|
||||
flowId: flow.id,
|
||||
type,
|
||||
bizId,
|
||||
bizType,
|
||||
status: 'PENDING',
|
||||
currentStep: 1,
|
||||
approvals: [],
|
||||
employeeId,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 计算第一步审批人
|
||||
const firstApprover = await resolveApprover(orgId, employeeId, steps[0])
|
||||
return { instance, firstApprover }
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据步骤配置解析审批人
|
||||
*/
|
||||
async function resolveApprover(orgId: string, employeeId: string, step: ApprovalStep): Promise<any> {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: employeeId, orgId },
|
||||
include: { dept: true, supervisor: true },
|
||||
})
|
||||
if (!employee) return null
|
||||
|
||||
if (step.approverType === 'SUPERVISOR') {
|
||||
return employee.supervisor ? { id: employee.supervisor.id, name: employee.supervisor.name } : null
|
||||
} else if (step.approverType === 'DEPT_HEAD') {
|
||||
// 部门负责人暂用部门创建人(简化实现)
|
||||
if (employee.dept) {
|
||||
return { id: employee.dept.createdBy, name: '部门负责人' }
|
||||
}
|
||||
return null
|
||||
} else if (step.approverType === 'PERSON' && step.approverId) {
|
||||
const approver = await prisma.employee.findFirst({ where: { id: step.approverId, orgId } })
|
||||
return approver ? { id: approver.id, name: approver.name } : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理审批
|
||||
*/
|
||||
export async function processApproval(
|
||||
orgId: string,
|
||||
instanceId: string,
|
||||
approverId: string,
|
||||
approverName: string,
|
||||
result: 'APPROVED' | 'REJECTED',
|
||||
comment?: string,
|
||||
): Promise<{ status: string; nextApprover?: any }> {
|
||||
const instance = await prisma.approvalInstance.findFirst({
|
||||
where: { id: instanceId, orgId },
|
||||
include: { flow: true },
|
||||
})
|
||||
if (!instance) throw { code: 'NOT_FOUND', message: '审批实例不存在' }
|
||||
if (instance.status !== 'PENDING') throw { code: 'VALIDATION_ERROR', message: '审批实例已处理' }
|
||||
|
||||
const steps = instance.flow.steps as unknown as ApprovalStep[]
|
||||
const currentStepConfig = steps.find(s => s.step === instance.currentStep)
|
||||
if (!currentStepConfig) throw { code: 'VALIDATION_ERROR', message: '步骤配置错误' }
|
||||
|
||||
// 记录审批结果
|
||||
const approvals = (instance.approvals as unknown as ApprovalRecord[]) || []
|
||||
approvals.push({
|
||||
step: instance.currentStep,
|
||||
approverId,
|
||||
approverName,
|
||||
result,
|
||||
comment,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (result === 'REJECTED') {
|
||||
await prisma.approvalInstance.update({
|
||||
where: { id: instanceId },
|
||||
data: { status: 'REJECTED', approvals: approvals as any },
|
||||
})
|
||||
return { status: 'REJECTED' }
|
||||
}
|
||||
|
||||
// 查找下一步
|
||||
const nextStepConfig = steps.find(s => s.step === instance.currentStep + 1)
|
||||
if (!nextStepConfig) {
|
||||
// 全部通过
|
||||
await prisma.approvalInstance.update({
|
||||
where: { id: instanceId },
|
||||
data: { status: 'APPROVED', approvals: approvals as any },
|
||||
})
|
||||
return { status: 'APPROVED' }
|
||||
}
|
||||
|
||||
// 进入下一步
|
||||
const nextApprover = await resolveApprover(orgId, instance.employeeId || '', nextStepConfig)
|
||||
await prisma.approvalInstance.update({
|
||||
where: { id: instanceId },
|
||||
data: { currentStep: instance.currentStep + 1, approvals: approvals as any },
|
||||
})
|
||||
return { status: 'PENDING', nextApprover }
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消审批
|
||||
*/
|
||||
export async function cancelApproval(orgId: string, instanceId: string): Promise<void> {
|
||||
await prisma.approvalInstance.update({
|
||||
where: { id: instanceId },
|
||||
data: { status: 'CANCELLED' },
|
||||
})
|
||||
}
|
||||
@@ -31,7 +31,7 @@ async function clampHousingFundBase(orgId: string, base: number, city?: string):
|
||||
return base
|
||||
}
|
||||
|
||||
function prevMonth(month: string): string {
|
||||
export function prevMonth(month: string): string {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
@@ -209,14 +209,14 @@ export async function getEmployeeDetail(orgId: string, id: string) {
|
||||
}
|
||||
|
||||
export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
// 身份证号查重
|
||||
// 证件号码查重
|
||||
if (data.idCardNumber) {
|
||||
const existing = await prisma.employee.findFirst({
|
||||
where: { orgId, idCardHash: sha256(data.idCardNumber) },
|
||||
select: { id: true, name: true, department: true, status: true },
|
||||
})
|
||||
if (existing) {
|
||||
throw { code: 'DUPLICATE_ID_CARD', message: `身份证号已存在:${existing.name}(${existing.department},${existing.status === 'ACTIVE' ? '在职' : '离职'}),请确认是否重复录入` }
|
||||
throw { code: 'DUPLICATE_ID_CARD', message: `证件号码已存在:${existing.name}(${existing.department},${existing.status === 'ACTIVE' ? '在职' : '离职'}),请确认是否重复录入` }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,6 +591,7 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
if (data.city !== undefined) updateData.city = data.city
|
||||
if (data.education !== undefined) updateData.education = data.education
|
||||
if (data.position !== undefined) updateData.position = data.position
|
||||
if (data.status !== undefined) updateData.status = data.status
|
||||
|
||||
// 参保城市变更:关闭旧城市在保记录,创建新城市记录
|
||||
if (data.city !== undefined && data.city !== employee.city) {
|
||||
|
||||
@@ -110,7 +110,7 @@ const HELP_SEED_DATA: KnowledgeSeed[] = [
|
||||
{ title: '可以在手机上使用吗', content: '可以。用手机浏览器打开本网站即可,手机版会自动显示底部导航栏。建议添加到手机桌面像App一样使用。苹果手机Safari打开点击底部分享按钮选择添加到主屏幕。安卓手机Chrome打开点击右上角菜单选择添加到主屏幕。', source: '使用帮助', category: '系统帮助-快速入门' },
|
||||
{ title: '第一次使用该从哪里开始', content: '建议按以下顺序:1添加员工信息,2填写合同信息,3设置社保基数和比例,4创建发薪批次,5有不懂的随时点帮助图标查看。不用担心填错,所有信息都可以随时修改。', source: '使用帮助', category: '系统帮助-快速入门' },
|
||||
{ title: '企业用工专家是什么', content: '这是一个帮您管理员工、合同、工资和社保的工具,可以把它理解为一个「人事小助手」,帮您把繁琐的人事工作变得简单。比如记录员工信息、提醒合同到期、计算工资社保、生成法律文档等。', source: '使用帮助', category: '系统帮助-快速入门' },
|
||||
{ title: '我的数据安全吗', content: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息如身份证号都经过加密存储。', source: '使用帮助', category: '系统帮助-常见问题' },
|
||||
{ title: '我的数据安全吗', content: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息如证件号码都经过加密存储。', source: '使用帮助', category: '系统帮助-常见问题' },
|
||||
{ title: '可以导出数据吗', content: '可以。在员工管理页面可以导出员工名单为Excel文件。工资批次也可以导出为Excel方便财务对账。', source: '使用帮助', category: '系统帮助-常见问题' },
|
||||
{ title: '可以多人同时使用吗', content: '可以。在设置页面可以添加多个HR账号,不同账号可以设置不同权限。比如一个管理员、几个普通HR。', source: '使用帮助', category: '系统帮助-常见问题' },
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from 'crypto'
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
// 从身份证号提取出生日期
|
||||
// 从证件号码提取出生日期
|
||||
export function extractBirthDateFromIdCard(idCard: string): Date | null {
|
||||
// 18位身份证:7-14位为出生日期 YYYYMMDD
|
||||
if (idCard.length === 18) {
|
||||
@@ -24,7 +24,7 @@ export function extractBirthDateFromIdCard(idCard: string): Date | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// 从身份证号提取性别(18位:第17位奇数为男,偶数为女;15位:第15位)
|
||||
// 从证件号码提取性别(18位:第17位奇数为男,偶数为女;15位:第15位)
|
||||
export function extractGenderFromIdCard(idCard: string): string | null {
|
||||
if (idCard.length === 18) {
|
||||
const genderCode = parseInt(idCard.substring(16, 17))
|
||||
|
||||
@@ -1928,7 +1928,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
|
||||
}),
|
||||
])
|
||||
|
||||
// 按身份证号去重(同一人可能有多条 Employee 记录),无身份证号时回退到 employeeId
|
||||
// 按证件号码去重(同一人可能有多条 Employee 记录),无证件号码时回退到 employeeId
|
||||
// 同时按风险类型去重(同一风险被重复创建解决多次,只取 estimatedLoss 最大的一条)
|
||||
const personBreakdown: Record<string, {
|
||||
personKey: string
|
||||
@@ -1988,7 +1988,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
|
||||
}
|
||||
|
||||
for (const r of employeeRiskDetails) {
|
||||
// 去重优先级:身份证号 > 姓名回退到姓名,避免同一人多条 Employee 记录被重复计算
|
||||
// 去重优先级:证件号码 > 姓名回退到姓名,避免同一人多条 Employee 记录被重复计算
|
||||
const personKey = r.employee?.idCardHash || r.employee?.name || r.employeeId || '_unknown'
|
||||
if (!personBreakdown[personKey]) {
|
||||
personBreakdown[personKey] = {
|
||||
|
||||
@@ -92,6 +92,11 @@ export async function createSpecialStatus(orgId: string, userId: string, data: a
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
// 合规校验:男职工不可选择三期
|
||||
if (data.type === 'PREGNANCY' && employee.gender === '男') {
|
||||
throw { code: 'VALIDATION_ERROR', message: '三期仅适用于女性员工,男职工不可选择三期' }
|
||||
}
|
||||
|
||||
// 三期自动计算
|
||||
let pregnancyData: any = {}
|
||||
if (data.type === 'PREGNANCY' && data.expectedDueDate) {
|
||||
|
||||
@@ -95,7 +95,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
||||
content: `解除劳动合同协议书
|
||||
|
||||
甲方(用人单位):{{companyName}}
|
||||
乙方(劳动者):{{employeeName}},身份证号:{{idCard}}
|
||||
乙方(劳动者):{{employeeName}},证件号码:{{idCard}}
|
||||
|
||||
甲乙双方经协商一致,就解除劳动合同事宜达成如下协议:
|
||||
|
||||
@@ -135,7 +135,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
||||
content: `解除劳动合同协议书
|
||||
|
||||
甲方(用人单位):{{companyName}}
|
||||
乙方(劳动者):{{employeeName}},身份证号:{{idCard}}
|
||||
乙方(劳动者):{{employeeName}},证件号码:{{idCard}}
|
||||
|
||||
乙方因个人原因主动提出离职,经甲乙双方友好协商,就解除劳动合同事宜达成如下协议:
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { encrypt } from '../lib/crypto'
|
||||
import { encrypt, decrypt } from '../lib/crypto'
|
||||
import { createDraft as createTerminationDraft, executeTermination } from './termination.service'
|
||||
import { createEmployee, addContract } from './contract.service'
|
||||
import { createEmployee, addContract, prevMonth } from './contract.service'
|
||||
import { runRiskDetection } from './risk.service'
|
||||
|
||||
// 13类流程定义
|
||||
@@ -69,10 +69,44 @@ export async function executeWorkProcess(processId: string, type: string, formDa
|
||||
const { employeeId, regularSalary } = formData
|
||||
if (employeeId) {
|
||||
if (regularSalary) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
|
||||
const oldSalary = employee ? Number(decrypt(employee.monthlySalary)) || 0 : 0
|
||||
const newSalary = Number(regularSalary) || 0
|
||||
await prisma.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: { monthlySalary: encrypt(String(regularSalary)) },
|
||||
})
|
||||
// 记录薪资变更(试用期薪资 → 转正薪资)
|
||||
if (oldSalary !== newSalary) {
|
||||
const now = new Date()
|
||||
const nowMonth = now.toISOString().slice(0, 7)
|
||||
await prisma.salaryChangeRecord.updateMany({
|
||||
where: { employeeId, endMonth: null },
|
||||
data: { endMonth: prevMonth(nowMonth) },
|
||||
})
|
||||
await prisma.salaryChangeRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId,
|
||||
oldSalary,
|
||||
newSalary,
|
||||
effectiveDate: now,
|
||||
effectiveMonth: nowMonth,
|
||||
endMonth: null,
|
||||
changeType: 'CONFIRM',
|
||||
reason: '试用期转正薪资调整',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
// 校验转正薪资与最新合同试用期薪资是否一致(提示性校验)
|
||||
const latestContract = await prisma.laborContract.findFirst({
|
||||
where: { employeeId, orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (latestContract?.probationSalary && latestContract.probationSalary !== newSalary) {
|
||||
console.warn(`[CONFIRM] 转正薪资 ¥${newSalary} 与合同试用期薪资 ¥${latestContract.probationSalary} 不一致,员工: ${employeeId}`)
|
||||
}
|
||||
await runRiskDetection(orgId)
|
||||
}
|
||||
}
|
||||
@@ -275,13 +309,13 @@ ${body}
|
||||
|
||||
const templates: Record<string, (data: any, org: string) => string> = {
|
||||
INCOME_CERT: (data, org) => wrapHtml('收入证明', `
|
||||
<div class="body">兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。</div>
|
||||
<div class="body">兹证明 ${data.employeeName || '___'}(证件号码:${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。</div>
|
||||
<div class="body">该员工近一年平均月收入为人民币 ${data.monthlyIncome || '___'} 元(税前)。</div>
|
||||
<div class="body">本证明仅用于 ${data.purpose || '___'},不作其他用途。</div>
|
||||
<div class="body">特此证明。</div>
|
||||
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
|
||||
LEAVING_CERT: (data, org) => wrapHtml('离职证明', `
|
||||
<div class="body">兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})自 ${data.hireDate || '___'} 至 ${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}。</div>
|
||||
<div class="body">兹证明 ${data.employeeName || '___'}(证件号码:${data.idCardNumber || '___'})自 ${data.hireDate || '___'} 至 ${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}。</div>
|
||||
<div class="body">该员工已于 ${data.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。</div>
|
||||
<div class="body">特此证明。</div>
|
||||
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
|
||||
|
||||
Reference in New Issue
Block a user