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:
selfrelease
2026-08-15 12:37:27 +08:00
parent 8cfdd566af
commit e1b5ae9aab
46 changed files with 3455 additions and 206 deletions
+162
View File
@@ -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' },
})
}
+4 -3
View File
@@ -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) {
+1 -1
View File
@@ -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: '系统帮助-常见问题' },
]
+2 -2
View File
@@ -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))
+2 -2
View File
@@ -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) {
+2 -2
View File
@@ -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}}
乙方因个人原因主动提出离职,经甲乙双方友好协商,就解除劳动合同事宜达成如下协议:
+38 -4
View File
@@ -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>`),