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:
@@ -61,6 +61,10 @@ import auditRoutes from './routes/audit.routes'
|
||||
import calendarRoutes from './routes/calendar.routes'
|
||||
import platformRoutes from './routes/platform.routes'
|
||||
import workProcessRoutes from './routes/work-process.routes'
|
||||
import departmentRoutes from './routes/department.routes'
|
||||
import positionRoutes from './routes/position.routes'
|
||||
import approvalRoutes from './routes/approval.routes'
|
||||
import supportRoutes from './routes/support.routes'
|
||||
import enterpriseTemplateRoutes from './routes/enterprise-template.routes'
|
||||
import specialStatusRoutes from './routes/special-status.routes'
|
||||
import companyFileRoutes from './routes/company-file.routes'
|
||||
@@ -93,6 +97,10 @@ app.use('/api/v1/audit', auditRoutes)
|
||||
app.use('/api/v1/calendar', calendarRoutes)
|
||||
app.use('/api/v1/platform', platformRoutes)
|
||||
app.use('/api/v1/work-processes', workProcessRoutes)
|
||||
app.use('/api/v1/departments', departmentRoutes)
|
||||
app.use('/api/v1/positions', positionRoutes)
|
||||
app.use('/api/v1/approvals', approvalRoutes)
|
||||
app.use('/api/v1/support', supportRoutes)
|
||||
app.use('/api/v1/enterprise-templates', enterpriseTemplateRoutes)
|
||||
app.use('/api/v1/special-statuses', specialStatusRoutes)
|
||||
app.use('/api/v1/company-files', companyFileRoutes)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 审批流路由
|
||||
* 提供审批流配置和实例管理
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { z } from 'zod'
|
||||
import { processApproval, cancelApproval } from '../services/approval.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const flowSchema = z.object({
|
||||
type: z.string(), // LEAVE / TERMINATION / SALARY_CHANGE / OTHER
|
||||
name: z.string().min(1),
|
||||
enabled: z.boolean().default(true),
|
||||
steps: z.array(z.object({
|
||||
step: z.number().int().min(1).max(3),
|
||||
approverType: z.enum(['SUPERVISOR', 'DEPT_HEAD', 'PERSON']),
|
||||
approverId: z.string().optional(),
|
||||
name: z.string(),
|
||||
})).min(1, '至少一个审批步骤').max(3, '最多三个审批步骤'),
|
||||
})
|
||||
|
||||
/** 获取审批流配置列表 */
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const flows = await prisma.approvalFlow.findMany({
|
||||
where: { orgId: req.user!.orgId! },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
res.json({ success: true, data: flows })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 创建/更新审批流配置(upsert by type) */
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = flowSchema.parse(req.body)
|
||||
const existing = await prisma.approvalFlow.findFirst({
|
||||
where: { orgId: req.user!.orgId!, type: data.type },
|
||||
})
|
||||
let flow
|
||||
if (existing) {
|
||||
flow = await prisma.approvalFlow.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...data, createdBy: req.user!.id },
|
||||
})
|
||||
} else {
|
||||
flow = await prisma.approvalFlow.create({
|
||||
data: {
|
||||
...data,
|
||||
orgId: req.user!.orgId!,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
res.json({ success: true, data: flow })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取待我审批的实例 */
|
||||
router.get('/pending', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const instances = await prisma.approvalInstance.findMany({
|
||||
where: { orgId: req.user!.orgId!, status: 'PENDING' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
})
|
||||
res.json({ success: true, data: instances })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 处理审批 */
|
||||
router.post('/:id/process', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { result, comment } = req.body as { result: 'APPROVED' | 'REJECTED'; comment?: string }
|
||||
if (!result || !['APPROVED', 'REJECTED'].includes(result)) {
|
||||
throw { code: 'VALIDATION_ERROR', message: 'result 必须为 APPROVED 或 REJECTED' }
|
||||
}
|
||||
const approverName = req.user!.id || '审批人'
|
||||
const outcome = await processApproval(req.user!.orgId!, id, req.user!.id, approverName, result, comment)
|
||||
res.json({ success: true, data: outcome })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 取消审批 */
|
||||
router.post('/:id/cancel', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
await cancelApproval(req.user!.orgId!, id)
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 部门管理路由
|
||||
* 提供部门的增删改查(树形结构)
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const createDeptSchema = z.object({
|
||||
name: z.string().min(1, '部门名称必填'),
|
||||
parentId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
description: z.string().max(200).optional(),
|
||||
})
|
||||
|
||||
const updateDeptSchema = createDeptSchema.partial()
|
||||
|
||||
/** 获取部门树 */
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const departments = await prisma.department.findMany({
|
||||
where: { orgId: req.user!.orgId! },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
include: { _count: { select: { employees: true, positions: true } } },
|
||||
})
|
||||
res.json({ success: true, data: departments })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 创建部门 */
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createDeptSchema.parse(req.body)
|
||||
let level = 0
|
||||
if (data.parentId) {
|
||||
const parent = await prisma.department.findFirst({ where: { id: data.parentId, orgId: req.user!.orgId! } })
|
||||
if (!parent) throw { code: 'NOT_FOUND', message: '父部门不存在' }
|
||||
level = parent.level + 1
|
||||
}
|
||||
const dept = await prisma.department.create({
|
||||
data: {
|
||||
...data,
|
||||
level,
|
||||
orgId: req.user!.orgId!,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: dept })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 更新部门 */
|
||||
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const data = updateDeptSchema.parse(req.body)
|
||||
// 防止循环引用
|
||||
if (data.parentId === id) throw { code: 'VALIDATION_ERROR', message: '不能将自身设为父部门' }
|
||||
let level: number | undefined
|
||||
if (data.parentId) {
|
||||
const parent = await prisma.department.findFirst({ where: { id: data.parentId, orgId: req.user!.orgId! } })
|
||||
if (!parent) throw { code: 'NOT_FOUND', message: '父部门不存在' }
|
||||
level = parent.level + 1
|
||||
} else if (data.parentId === null) {
|
||||
level = 0
|
||||
}
|
||||
const dept = await prisma.department.update({
|
||||
where: { id },
|
||||
data: { ...data, level },
|
||||
})
|
||||
res.json({ success: true, data: dept })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 删除部门 */
|
||||
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
// 检查是否有子部门
|
||||
const children = await prisma.department.findFirst({ where: { parentId: id, orgId: req.user!.orgId! } })
|
||||
if (children) throw { code: 'VALIDATION_ERROR', message: '请先删除子部门' }
|
||||
// 检查是否有关联员工
|
||||
const employees = await prisma.employee.findFirst({ where: { departmentId: id, orgId: req.user!.orgId! } })
|
||||
if (employees) throw { code: 'VALIDATION_ERROR', message: '该部门下仍有员工,无法删除' }
|
||||
await prisma.department.delete({ where: { id } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -3,7 +3,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import { createEvidence } from '../services/evidence.service'
|
||||
import prisma from '../lib/prisma'
|
||||
import { sha256 } from '../lib/crypto'
|
||||
import { sha256, decrypt } from '../lib/crypto'
|
||||
import {
|
||||
createEmployeeSchema,
|
||||
updateEmployeeSchema,
|
||||
@@ -116,6 +116,27 @@ router.get('/check-id-card', authMiddleware, async (req: AuthRequest, res, next)
|
||||
}
|
||||
})
|
||||
|
||||
// 手机号查重
|
||||
router.get('/check-phone', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const phone = req.query.phone as string
|
||||
if (!phone || phone.length < 11) {
|
||||
return res.json({ success: true, data: { exists: false } })
|
||||
}
|
||||
// 手机号加密存储,需遍历匹配(量小可接受)
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
select: { id: true, name: true, department: true, status: true, phone: true },
|
||||
})
|
||||
const matched = employees.find(e => {
|
||||
try { return e.phone ? decrypt(e.phone) === phone : false } catch { return false }
|
||||
})
|
||||
res.json({ success: true, data: { exists: !!matched, employee: matched ? { id: matched.id, name: matched.name, department: matched.department, status: matched.status } : undefined } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createEmployeeSchema.parse(req.body)
|
||||
|
||||
@@ -287,7 +287,7 @@ router.get('/roster', authMiddleware, async (req: AuthRequest, res: Response, ne
|
||||
{ header: '状态', key: 'status', width: 8 },
|
||||
{ header: '入职日期', key: 'hireDate', width: 12 },
|
||||
{ header: '手机号', key: 'phone', width: 13 },
|
||||
{ header: '身份证号', key: 'idCardNumber', width: 20 },
|
||||
{ header: '证件号码', key: 'idCardNumber', width: 20 },
|
||||
{ header: '月工资', key: 'monthlySalary', width: 10 },
|
||||
{ header: '社保基数', key: 'socialInsBase', width: 10 },
|
||||
{ header: '公积金基数', key: 'housingFundBase', width: 10 },
|
||||
@@ -478,7 +478,7 @@ router.get('/tax-declaration', authMiddleware, requireAdmin, async (req: AuthReq
|
||||
let seq = 0
|
||||
for (const e of entries) {
|
||||
seq++
|
||||
// 解密身份证号
|
||||
// 解密证件号码
|
||||
let idCard: string = ''
|
||||
try { if (e.employee.idCardNumber) idCard = decrypt(e.employee.idCardNumber) || '' } catch { idCard = e.employee.idCardNumber || '' }
|
||||
|
||||
|
||||
@@ -18,17 +18,17 @@ function contentDisposition(filename: string): string {
|
||||
const router = Router()
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
|
||||
|
||||
// 身份证号格式校验(18位正则 + 校验位算法)
|
||||
// 证件号码格式校验(18位正则 + 校验位算法)
|
||||
function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; error?: string } {
|
||||
if (!idCard) return { valid: true }
|
||||
const s = idCard.trim()
|
||||
// 15位身份证号升级为18位
|
||||
// 15位证件号码升级为18位
|
||||
if (/^\d{15}$/.test(s)) {
|
||||
const upgraded = upgrade15To18(s)
|
||||
return { valid: true, upgraded }
|
||||
}
|
||||
if (!/^\d{17}[\dXx]$/.test(s)) {
|
||||
return { valid: false, error: '身份证号格式错误(应为18位)' }
|
||||
return { valid: false, error: '证件号码格式错误(应为18位)' }
|
||||
}
|
||||
// 校验位算法
|
||||
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
@@ -36,7 +36,7 @@ function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; er
|
||||
const sum = s.substring(0, 17).split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
|
||||
const expected = checkCodes[sum % 11]
|
||||
if (s.charAt(17).toUpperCase() !== expected) {
|
||||
return { valid: false, error: '身份证号校验位错误' }
|
||||
return { valid: false, error: '证件号码校验位错误' }
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
@@ -124,7 +124,7 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
|
||||
const rows = XLSX.utils.sheet_to_json(empSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), department: val(getField(r, '部门')) || '未分配', hireDate: getField(r, '入职日期'), salary: num(getField(r, '月工资')), phone: val(getField(r, '手机号')), idCard: val(getField(r, '身份证号')), city: val(getField(r, '参保城市')) || null, status: 'normal', errors: [] as string[], warnings: [] as string[] }
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), department: val(getField(r, '部门')) || '未分配', hireDate: getField(r, '入职日期'), salary: num(getField(r, '月工资')), phone: val(getField(r, '手机号')), idCard: val(getField(r, '证件号码')), city: val(getField(r, '参保城市')) || null, status: 'normal', errors: [] as string[], warnings: [] as string[] }
|
||||
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
|
||||
const hireDate = parseDate(getField(r, '入职日期'))
|
||||
if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') }
|
||||
@@ -144,8 +144,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
|
||||
const rows = XLSX.utils.sheet_to_json(contractSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), contractType: val(getField(r, '合同类型')), startDate: getField(r, '合同开始日期'), endDate: getField(r, '合同结束日期'), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), contractType: val(getField(r, '合同类型')), startDate: getField(r, '合同开始日期'), endDate: getField(r, '合同结束日期'), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
|
||||
const sd = parseDate(getField(r, '合同开始日期'))
|
||||
if (!sd) { row.status = 'error'; row.errors.push('开始日期格式错误') }
|
||||
if (row.status === 'error') preview.errors.push({ sheet: '劳动合同', row: i + 2, name: row.name, errors: row.errors })
|
||||
@@ -159,8 +159,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const otType = val(getField(r, '加班类型')) || '工作日加班'
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), hours: num(getField(r, '加班时长')), otType, status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), hours: num(getField(r, '加班时长')), otType, status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
|
||||
const dt = parseDate(getField(r, '日期'))
|
||||
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
|
||||
if (row.status === 'error') preview.errors.push({ sheet: '加班记录', row: i + 2, name: row.name, errors: row.errors })
|
||||
@@ -173,8 +173,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
|
||||
const rows = XLSX.utils.sheet_to_json(discSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), violationType: val(getField(r, '违纪类型')), description: val(getField(r, '描述')), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), violationType: val(getField(r, '违纪类型')), description: val(getField(r, '描述')), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
|
||||
if (row.status === 'error') preview.errors.push({ sheet: '违纪记录', row: i + 2, name: row.name, errors: row.errors })
|
||||
preview.disciplinary.push(row)
|
||||
}
|
||||
@@ -185,8 +185,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
|
||||
const rows = XLSX.utils.sheet_to_json(attSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), attStatus: val(getField(r, '考勤状态')), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), attStatus: val(getField(r, '考勤状态')), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
|
||||
const dt = parseDate(getField(r, '日期'))
|
||||
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
|
||||
if (row.status === 'error') preview.errors.push({ sheet: '考勤记录', row: i + 2, name: row.name, errors: row.errors })
|
||||
@@ -259,8 +259,8 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
const salary = String(num(getField(r, '月工资')))
|
||||
if (salary === '0') { result.skipped++; result.errors.push(`员工第${i + 2}行:月工资为空`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '月工资为空' }); continue }
|
||||
|
||||
let idCard = val(getField(r, '身份证号'))
|
||||
if (!idCard) { result.skipped++; result.errors.push(`员工第${i + 2}行:身份证号为空,跳过`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '身份证号为空' }); continue }
|
||||
let idCard = val(getField(r, '证件号码'))
|
||||
if (!idCard) { result.skipped++; result.errors.push(`员工第${i + 2}行:证件号码为空,跳过`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '证件号码为空' }); continue }
|
||||
if (idCard) {
|
||||
const idCheck = validateIdCard(idCard)
|
||||
if (!idCheck.valid) { result.skipped++; result.errors.push(`员工第${i + 2}行:${idCheck.error}`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: idCheck.error }); continue }
|
||||
@@ -329,8 +329,8 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
const msg = e?.message || ''
|
||||
if (msg.includes('Unique constraint')) {
|
||||
result.duplicates++
|
||||
result.errors.push(`员工第${i + 2}行:该员工已存在(身份证号重复),跳过`)
|
||||
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'duplicate', message: '身份证号重复' })
|
||||
result.errors.push(`员工第${i + 2}行:该员工已存在(证件号码重复),跳过`)
|
||||
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'duplicate', message: '证件号码重复' })
|
||||
} else if (msg.includes('invalid') || msg.includes('validation')) {
|
||||
result.skipped++
|
||||
result.errors.push(`员工第${i + 2}行:数据格式不正确,请检查各项填写`)
|
||||
@@ -353,7 +353,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
const idCard = val(getField(r, '证件号码'))
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
|
||||
if (!empId) { result.errors.push(`合同第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const startDate = parseDate(getField(r, '合同开始日期'))
|
||||
@@ -399,7 +399,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
const idCard = val(getField(r, '证件号码'))
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
|
||||
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const date = parseDate(getField(r, '日期'))
|
||||
@@ -429,7 +429,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
const idCard = val(getField(r, '证件号码'))
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
|
||||
if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const date = parseDate(getField(r, '日期'))
|
||||
@@ -462,7 +462,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
const idCard = val(getField(r, '证件号码'))
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
|
||||
if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const date = parseDate(getField(r, '日期'))
|
||||
@@ -495,30 +495,30 @@ router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response)
|
||||
const wb = XLSX.utils.book_new()
|
||||
|
||||
const empData = [
|
||||
{ '姓名*': '张三', '部门': '技术部', '性别(选填,留空自动识别)': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期*': '2023-03-01', '月工资*': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '参保城市': '北京', '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
|
||||
{ '姓名*': '张三', '部门': '技术部', '性别(选填,留空自动识别)': '男', '手机号': '13800138000', '证件号码': '110101199001011234', '入职日期*': '2023-03-01', '月工资*': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '参保城市': '北京', '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
|
||||
]
|
||||
const empWs = XLSX.utils.json_to_sheet(empData, { header: ['姓名*', '部门', '性别(选填,留空自动识别)', '手机号', '身份证号', '入职日期*', '月工资*', '社保基数', '公积金基数', '专项附加扣除', '参保城市', '紧急联系人', '紧急联系电话', '住址', '开户行', '银行账号', '孕期', '医疗期', '工伤'] })
|
||||
const empWs = XLSX.utils.json_to_sheet(empData, { header: ['姓名*', '部门', '性别(选填,留空自动识别)', '手机号', '证件号码', '入职日期*', '月工资*', '社保基数', '公积金基数', '专项附加扣除', '参保城市', '紧急联系人', '紧急联系电话', '住址', '开户行', '银行账号', '孕期', '医疗期', '工伤'] })
|
||||
// 设置示例行样式(灰色背景)
|
||||
empWs['!cols'] = [{ wch: 10 }, { wch: 12 }, { wch: 22 }, { wch: 13 }, { wch: 20 }, { wch: 12 }, { wch: 10 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 10 }, { wch: 10 }, { wch: 13 }, { wch: 18 }, { wch: 10 }, { wch: 18 }, { wch: 6 }, { wch: 6 }, { wch: 6 }]
|
||||
XLSX.utils.book_append_sheet(wb, empWs, '员工信息')
|
||||
|
||||
const contractData = [
|
||||
{ '姓名*': '张三', '身份证号': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期*': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
|
||||
{ '姓名*': '张三', '证件号码': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期*': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同')
|
||||
|
||||
const otData = [
|
||||
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
|
||||
{ '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
|
||||
|
||||
const discData = [
|
||||
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
|
||||
{ '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
|
||||
|
||||
const attData = [
|
||||
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
|
||||
{ '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
|
||||
|
||||
@@ -558,7 +558,7 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
|
||||
}
|
||||
|
||||
function findEmp(r: any) {
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
const idCard = val(getField(r, '证件号码'))
|
||||
if (idCard) {
|
||||
const emp = empByHash.get(sha256(idCard))
|
||||
if (emp) return emp
|
||||
@@ -778,10 +778,10 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
|
||||
router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: Response) => {
|
||||
const wb = XLSX.utils.book_new()
|
||||
|
||||
// 合并考勤+加班为一个Sheet,减少重复录入姓名身份证号
|
||||
// 合并考勤+加班为一个Sheet,减少重复录入姓名证件号码
|
||||
const attOtData = [{
|
||||
'姓名': '张三',
|
||||
'身份证号': '110101199001011234',
|
||||
'证件号码': '110101199001011234',
|
||||
'日期': '2024-06-01',
|
||||
'考勤状态': '正常',
|
||||
'上班时间': '09:00',
|
||||
@@ -798,16 +798,16 @@ router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: R
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, attOtWs, '考勤与加班')
|
||||
|
||||
const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
|
||||
const salaryData = [{ '姓名': '张三', '证件号码': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(salaryData), '薪资调整')
|
||||
|
||||
const socialData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
|
||||
const socialData = [{ '姓名': '张三', '证件号码': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(socialData), '社保变动')
|
||||
|
||||
const hfData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
|
||||
const hfData = [{ '姓名': '张三', '证件号码': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(hfData), '公积金变动')
|
||||
|
||||
const discData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' }]
|
||||
const discData = [{ '姓名': '张三', '证件号码': '110101199001011234', '日期': '2024-06-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
|
||||
|
||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
||||
@@ -847,7 +847,7 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
const idCard = val(getField(r, '证件号码'))
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
|
||||
if (!empId) { result.errors.push(`第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const entryId = entryByEmp.get(empId)
|
||||
@@ -906,8 +906,8 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
|
||||
router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Response) => {
|
||||
const wb = XLSX.utils.book_new()
|
||||
const data = [
|
||||
{ '姓名*': '张三', '身份证号*': '110101199001011234', '基本工资': 10000, '加班费': 500, '津贴': 800, '扣款': 0, '奖金': 2000 },
|
||||
{ '姓名*': '李四', '身份证号*': '110101199002021234', '基本工资': 12000, '加班费': 0, '津贴': 600, '扣款': 100, '奖金': 0 },
|
||||
{ '姓名*': '张三', '证件号码*': '110101199001011234', '基本工资': 10000, '加班费': 500, '津贴': 800, '扣款': 0, '奖金': 2000 },
|
||||
{ '姓名*': '李四', '证件号码*': '110101199002021234', '基本工资': 12000, '加班费': 0, '津贴': 600, '扣款': 100, '奖金': 0 },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '工资表')
|
||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
||||
@@ -921,8 +921,8 @@ router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Respons
|
||||
router.get('/special-deduction/template', authMiddleware, (_req: AuthRequest, res: Response) => {
|
||||
const wb = XLSX.utils.book_new()
|
||||
const data = [
|
||||
{ '姓名*': '张三', '身份证号': '110101199001011234', '子女教育': 1000, '赡养老人': 2000, '住房': 1500, '继续教育': 0, '婴幼儿照护': 0, '备注': '' },
|
||||
{ '姓名*': '李四', '身份证号': '110101199002021234', '子女教育': 0, '赡养老人': 1000, '住房': 0, '继续教育': 400, '婴幼儿照护': 1000, '备注': '继续教育证书' },
|
||||
{ '姓名*': '张三', '证件号码': '110101199001011234', '子女教育': 1000, '赡养老人': 2000, '住房': 1500, '继续教育': 0, '婴幼儿照护': 0, '备注': '' },
|
||||
{ '姓名*': '李四', '证件号码': '110101199002021234', '子女教育': 0, '赡养老人': 1000, '住房': 0, '继续教育': 400, '婴幼儿照护': 1000, '备注': '继续教育证书' },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '专项附加扣除')
|
||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
||||
@@ -953,7 +953,7 @@ router.post('/special-deduction', authMiddleware, requireAdmin, upload.single('f
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const name = val(getField(r, '姓名'))
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
const idCard = val(getField(r, '证件号码'))
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(name)
|
||||
if (!empId) { result.skipped++; result.errors.push(`第${i + 2}行:找不到员工「${name}」`); result.details.push({ row: i + 2, name, status: 'skipped', message: '找不到员工' }); continue }
|
||||
|
||||
|
||||
@@ -283,7 +283,18 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
|
||||
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
|
||||
})
|
||||
employees = terminations.map(t => t.employee)
|
||||
// SEVERANCE 批次:仅包含已审批通过(APPROVED/EXECUTING/COMPLETED)且有补偿金的离职记录
|
||||
if (type === 'SEVERANCE') {
|
||||
const eligibleTerms = terminations.filter(t =>
|
||||
(t.status === 'APPROVED' || t.status === 'EXECUTING' || t.status === 'COMPLETED') &&
|
||||
(t.compensation > 0 || (t.compensationBreakdown as any)?.total > 0)
|
||||
)
|
||||
employees = eligibleTerms.map(t => t.employee)
|
||||
// 缓存 terminationRecord 以便后续 entry 创建时读取补偿金
|
||||
;(req as any)._severanceTerms = new Map(eligibleTerms.map(t => [t.employeeId, t]))
|
||||
} else {
|
||||
employees = terminations.map(t => t.employee)
|
||||
}
|
||||
} else {
|
||||
employees = await prisma.employee.findMany({
|
||||
where: {
|
||||
@@ -357,14 +368,33 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||
}
|
||||
|
||||
// SEVERANCE 批次:从离职记录读取补偿金作为应发金额,不走工资/社保/个税计算
|
||||
let severanceAmount = 0
|
||||
let severanceBreakdown: any = null
|
||||
if (type === 'SEVERANCE') {
|
||||
const severanceTerms: Map<string, any> = (req as any)._severanceTerms || new Map()
|
||||
const termRecord = severanceTerms.get(emp.id)
|
||||
if (termRecord) {
|
||||
severanceBreakdown = termRecord.compensationBreakdown
|
||||
// 优先取 compensationBreakdown.total(含手动调整),否则取 compensation
|
||||
severanceAmount = (severanceBreakdown as any)?.total || termRecord.compensation || 0
|
||||
baseSalary = severanceAmount
|
||||
}
|
||||
}
|
||||
|
||||
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,已扣则跳过
|
||||
// SEVERANCE 批次:补偿金不走社保/个税计算,直接作为应发和实发金额
|
||||
let calcResult: any
|
||||
try {
|
||||
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type)
|
||||
} catch (calcErr: any) {
|
||||
// 单个员工计算失败不阻塞整个批次,记录错误并使用零值
|
||||
failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' })
|
||||
calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: baseSalary + overtimePay + allowance + bonus - deduction, netPay: baseSalary + overtimePay + allowance + bonus - deduction }
|
||||
if (type === 'SEVERANCE' && severanceAmount > 0) {
|
||||
calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: severanceAmount, netPay: severanceAmount }
|
||||
} else {
|
||||
try {
|
||||
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type)
|
||||
} catch (calcErr: any) {
|
||||
// 单个员工计算失败不阻塞整个批次,记录错误并使用零值
|
||||
failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' })
|
||||
calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: baseSalary + overtimePay + allowance + bonus - deduction, netPay: baseSalary + overtimePay + allowance + bonus - deduction }
|
||||
}
|
||||
}
|
||||
|
||||
// 风险提示
|
||||
|
||||
@@ -940,7 +940,7 @@ router.get('/resignation/:id/certificate', portalAuth, async (req: any, res, nex
|
||||
}
|
||||
} else {
|
||||
content = `<h1>解除/终止劳动合同证明书</h1>
|
||||
<p>兹证明 ${variables.employeeName}(身份证号:${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate} 因 ${reason} 原因,正式解除/终止劳动合同。</p>
|
||||
<p>兹证明 ${variables.employeeName}(证件号码:${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate} 因 ${reason} 原因,正式解除/终止劳动合同。</p>
|
||||
<p>经济补偿金已结清:¥${variables.compensation}。社保截止月份:${variables.socialInsEndMonth || '—'},公积金截止月份:${variables.housingFundEndMonth || '—'}。</p>
|
||||
<p>特此证明。</p>
|
||||
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>`
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 岗位字典路由
|
||||
* 提供岗位的增删改查
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const createPositionSchema = z.object({
|
||||
name: z.string().min(1, '岗位名称必填'),
|
||||
departmentId: z.string().nullable().optional(),
|
||||
headcount: z.number().int().min(0).default(0),
|
||||
level: z.string().max(20).optional(),
|
||||
description: z.string().max(200).optional(),
|
||||
})
|
||||
|
||||
/** 获取岗位列表 */
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const positions = await prisma.position.findMany({
|
||||
where: { orgId: req.user!.orgId! },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { department: { select: { id: true, name: true } } },
|
||||
})
|
||||
res.json({ success: true, data: positions })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 创建岗位 */
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createPositionSchema.parse(req.body)
|
||||
const position = await prisma.position.create({
|
||||
data: {
|
||||
...data,
|
||||
orgId: req.user!.orgId!,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: position })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 更新岗位 */
|
||||
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const data = createPositionSchema.partial().parse(req.body)
|
||||
const position = await prisma.position.update({ where: { id }, data })
|
||||
res.json({ success: true, data: position })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 删除岗位 */
|
||||
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
await prisma.position.delete({ where: { id } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -94,7 +94,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
whereBase.contracts = { none: {} }
|
||||
}
|
||||
|
||||
// 当有 contractStatus(非 unsigned)筛选或身份证号搜索时,需要先查全部再过滤后分页
|
||||
// 当有 contractStatus(非 unsigned)筛选或证件号码搜索时,需要先查全部再过滤后分页
|
||||
const needPostFilter = (!!contractStatus && contractStatus !== 'unsigned') || isIdCardSearch
|
||||
|
||||
const [dbTotal, employees] = await Promise.all([
|
||||
@@ -170,7 +170,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
const isResigned = e.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today)
|
||||
const isPreHire = !isResigned && e.hireDate > todayEnd
|
||||
const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE')
|
||||
// 身份证号脱敏显示
|
||||
// 证件号码脱敏显示
|
||||
let idCardMasked: string | null = null
|
||||
if (e.idCardNumber) {
|
||||
try {
|
||||
@@ -250,7 +250,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
result = result.filter((e) => e.contractStatus === contractStatus)
|
||||
}
|
||||
|
||||
// 身份证号后N位搜索:在内存中过滤(idCardNumber 已解密为明文)
|
||||
// 证件号码后N位搜索:在内存中过滤(idCardNumber 已解密为明文)
|
||||
if (isIdCardSearch) {
|
||||
result = result.filter((e: any) => {
|
||||
if (!e.idCardNumber) return false
|
||||
@@ -1025,7 +1025,7 @@ router.get('/:employeeId/disciplinary/:recordId/certificate', authMiddleware, as
|
||||
|
||||
const content = `违纪确认证明
|
||||
|
||||
兹证明 ${record.employee.name}(身份证号:${idCard || '___'})系我单位员工,于 ${record.violationDate.toISOString().slice(0, 10)} 发生以下违纪行为:
|
||||
兹证明 ${record.employee.name}(证件号码:${idCard || '___'})系我单位员工,于 ${record.violationDate.toISOString().slice(0, 10)} 发生以下违纪行为:
|
||||
|
||||
违纪类型:${typeMap[record.violationType] || record.violationType}
|
||||
严重程度:${severityMap[record.severity] || record.severity}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 客服工作台路由
|
||||
* 提供工单管理、客户会话、租户数据穿透
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 客服权限中间件
|
||||
const supportMiddleware = (req: AuthRequest, res: any, next: any) => {
|
||||
if (req.user!.role !== 'SUPPORT' && req.user!.role !== 'SUPER_ADMIN') {
|
||||
return res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: '仅客服或超级管理员可访问' } })
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
const createTicketSchema = z.object({
|
||||
title: z.string().min(1, '标题必填'),
|
||||
content: z.string().min(1, '内容必填'),
|
||||
category: z.string().optional(),
|
||||
priority: z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']).default('NORMAL'),
|
||||
})
|
||||
|
||||
const createMessageSchema = z.object({
|
||||
content: z.string().min(1, '内容必填'),
|
||||
})
|
||||
|
||||
// ==================== 工单管理 ====================
|
||||
|
||||
/** 获取工单列表(客服看全部,企业用户看自己租户的) */
|
||||
router.get('/tickets', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
|
||||
const where = isSupport ? {} : { orgId: req.user!.orgId! }
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
org: { select: { id: true, name: true } },
|
||||
_count: { select: { messages: true } },
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: tickets })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取工单详情 */
|
||||
router.get('/tickets/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
org: { select: { id: true, name: true } },
|
||||
messages: { orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
})
|
||||
if (!ticket) throw { code: 'NOT_FOUND', message: '工单不存在' }
|
||||
res.json({ success: true, data: ticket })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 创建工单(企业用户提交) */
|
||||
router.post('/tickets', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createTicketSchema.parse(req.body)
|
||||
const ticket = await prisma.ticket.create({
|
||||
data: {
|
||||
...data,
|
||||
orgId: req.user!.orgId!,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: ticket })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 回复工单 */
|
||||
router.post('/tickets/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const data = createMessageSchema.parse(req.body)
|
||||
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
|
||||
const message = await prisma.ticketMessage.create({
|
||||
data: {
|
||||
ticketId: id,
|
||||
content: data.content,
|
||||
senderId: req.user!.id,
|
||||
senderRole: isSupport ? 'SUPPORT' : 'USER',
|
||||
},
|
||||
})
|
||||
// 客服回复时更新工单状态为处理中
|
||||
if (isSupport) {
|
||||
await prisma.ticket.update({ where: { id }, data: { status: 'IN_PROGRESS', assigneeId: req.user!.id } })
|
||||
}
|
||||
res.json({ success: true, data: message })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 关闭工单 */
|
||||
router.post('/tickets/:id/close', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
await prisma.ticket.update({ where: { id }, data: { status: 'CLOSED' } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 转派工单 */
|
||||
router.post('/tickets/:id/assign', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { assigneeId } = req.body
|
||||
await prisma.ticket.update({ where: { id }, data: { assigneeId } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 客户会话 ====================
|
||||
|
||||
/** 获取会话列表 */
|
||||
router.get('/chat/sessions', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
|
||||
const where = isSupport ? { supportUserId: req.user!.id } : { orgId: req.user!.orgId! }
|
||||
const sessions = await prisma.chatSession.findMany({
|
||||
where,
|
||||
orderBy: { lastMessageAt: 'desc' },
|
||||
include: { org: { select: { id: true, name: true } } },
|
||||
})
|
||||
res.json({ success: true, data: sessions })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取会话消息 */
|
||||
router.get('/chat/sessions/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const messages = await prisma.chatMessage.findMany({
|
||||
where: { sessionId: id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
res.json({ success: true, data: messages })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 发送会话消息 */
|
||||
router.post('/chat/sessions/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { content } = req.body as { content: string }
|
||||
if (!content) throw { code: 'VALIDATION_ERROR', message: '内容必填' }
|
||||
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
|
||||
const session = await prisma.chatSession.findUnique({ where: { id } })
|
||||
if (!session) throw { code: 'NOT_FOUND', message: '会话不存在' }
|
||||
|
||||
const message = await prisma.chatMessage.create({
|
||||
data: {
|
||||
sessionId: id,
|
||||
content,
|
||||
senderId: req.user!.id,
|
||||
senderRole: isSupport ? 'SUPPORT' : 'USER',
|
||||
},
|
||||
})
|
||||
await prisma.chatSession.update({
|
||||
where: { id },
|
||||
data: {
|
||||
lastMessage: content,
|
||||
lastMessageAt: new Date(),
|
||||
unreadBySupport: isSupport ? session.unreadBySupport : session.unreadBySupport + 1,
|
||||
unreadByUser: isSupport ? session.unreadByUser + 1 : session.unreadByUser,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: message })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 租户数据穿透 ====================
|
||||
|
||||
/** 获取租户列表(客服用) */
|
||||
router.get('/tenants', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const tenants = await prisma.organization.findMany({
|
||||
select: {
|
||||
id: true, name: true, plan: true, maxEmployees: true,
|
||||
contactName: true, contactPhone: true, createdAt: true,
|
||||
_count: { select: { employees: true, users: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: tenants })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取租户数据概览(客服代查看) */
|
||||
router.get('/tenants/:orgId/overview', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { orgId } = req.params
|
||||
const [employeeCount, activeContracts, pendingApprovals, openTickets] = await Promise.all([
|
||||
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
|
||||
prisma.laborContract.count({ where: { orgId } }),
|
||||
prisma.approvalInstance.count({ where: { orgId, status: 'PENDING' } }),
|
||||
prisma.ticket.count({ where: { orgId, status: { in: ['OPEN', 'IN_PROGRESS'] } } }),
|
||||
])
|
||||
res.json({
|
||||
success: true,
|
||||
data: { employeeCount, activeContracts, pendingApprovals, openTickets },
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -27,7 +27,7 @@ const REQUIRED_FIELDS: Record<string, string[]> = {
|
||||
// 必填字段中文标签映射
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
name: '员工姓名', department: '部门', hireDate: '入职日期', phone: '手机号',
|
||||
idCardNumber: '身份证号', employeeId: '员工ID', contractId: '合同ID',
|
||||
idCardNumber: '证件号码', employeeId: '员工ID', contractId: '合同ID',
|
||||
contractStartDate: '合同开始日期', confirmDate: '转正日期',
|
||||
newEndDate: '新到期日期', newStartDate: '新合同开始日期',
|
||||
suspendDate: '中止日期', employeeName: '员工姓名',
|
||||
@@ -35,11 +35,38 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
leaveDate: '离职日期', agreementStartDate: '协议开始日期',
|
||||
}
|
||||
|
||||
/** 日期字段对配置:各流程类型的开始/结束日期字段对 */
|
||||
const DATE_RANGE_FIELDS: Record<string, Array<{ start: string; end: string; startLabel: string; endLabel: string }>> = {
|
||||
HIRE: [{ start: 'contractStartDate', end: 'contractEndDate', startLabel: '合同开始日期', endLabel: '合同结束日期' }],
|
||||
CUSTOM_CONTRACT: [{ start: 'contractStartDate', end: 'contractEndDate', startLabel: '合同开始日期', endLabel: '合同结束日期' }],
|
||||
RENEW: [{ start: 'newStartDate', end: 'newEndDate', startLabel: '新合同开始日期', endLabel: '新合同结束日期' }],
|
||||
FLEXIBLE: [{ start: 'agreementStartDate', end: 'agreementEndDate', startLabel: '协议开始日期', endLabel: '协议结束日期' }],
|
||||
}
|
||||
|
||||
/** 校验日期前后关系:结束日期不能早于开始日期 */
|
||||
function validateWorkProcessDateRange(type: string, formData: Record<string, any>): string | null {
|
||||
const pairs = DATE_RANGE_FIELDS[type]
|
||||
if (!pairs) return null
|
||||
for (const pair of pairs) {
|
||||
const start = formData[pair.start]
|
||||
const end = formData[pair.end]
|
||||
if (start && end && new Date(end) < new Date(start)) {
|
||||
return `${pair.endLabel}不能早于${pair.startLabel}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 创建办理(含草稿)
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = createWorkProcessSchema.parse(req.body)
|
||||
const { type, title, employeeId, formData, status, remark } = data
|
||||
// 后端日期前后关系校验(双保险)
|
||||
const dateError = validateWorkProcessDateRange(type, formData || {})
|
||||
if (dateError) {
|
||||
return res.status(400).json({ success: false, error: { code: 'INVALID_DATE_RANGE', message: dateError } })
|
||||
}
|
||||
const process = await (prisma as any).workProcess.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
|
||||
@@ -5,7 +5,7 @@ export const createEmployeeSchema = z.object({
|
||||
department: z.string().min(1, '部门不能为空').max(50, '部门最多50个字'),
|
||||
hireDate: z.string().datetime(),
|
||||
monthlySalary: z.string().min(1, '月薪不能为空'),
|
||||
idCardNumber: z.string().min(18, '身份证号不能为空且必须18位').max(18, '身份证号必须18位'),
|
||||
idCardNumber: z.string().min(18, '证件号码不能为空且必须18位').max(18, '证件号码必须18位'),
|
||||
gender: z.enum(['男', '女']).optional(),
|
||||
femaleWorkerType: z.enum(['CADRE', 'WORKER']).optional(),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||
@@ -50,6 +50,8 @@ export const updateEmployeeSchema = z.object({
|
||||
city: z.string().max(20).optional(),
|
||||
education: z.string().max(20).optional(),
|
||||
position: z.string().max(50).optional(),
|
||||
status: z.enum(['ACTIVE', 'PENDING_ONBOARD', 'RESIGNED', 'BLACKLISTED']).optional(),
|
||||
cityChangeReason: z.string().max(200).optional(),
|
||||
})
|
||||
|
||||
export const batchRenewSchema = z.object({
|
||||
|
||||
@@ -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