feat: AIHR 智能人力资源管理系统初始提交
- 员工花名册管理(加密存储、导入导出) - 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条) - 社保公积金(多城市配置、版本管理、基数调整) - 解聘管理(6步流程、证据链、工作交接) - AI 助手(合同审查、风险预测、RAG 知识库) - Dashboard 仪表盘 - 设置与通知
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { chat, chatStream, reviewContract, matchCase, predictRisks } from '../services/ai.service'
|
||||
import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable } from '../services/rag.service'
|
||||
import prisma from '../lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const PLAN_LIMITS: Record<string, { chat: number; review: number; case: number }> = {
|
||||
FREE: { chat: 10, review: 3, case: 3 },
|
||||
PRO: { chat: 100, review: 20, case: 20 },
|
||||
ENTERPRISE: { chat: 0, review: 0, case: 0 },
|
||||
}
|
||||
|
||||
async function checkUsageLimit(orgId: string, type: 'chat' | 'review' | 'case'): Promise<void> {
|
||||
const org = await prisma.organization.findUnique({ where: { id: orgId } })
|
||||
if (!org) return
|
||||
const limits = PLAN_LIMITS[org.plan] || PLAN_LIMITS.FREE
|
||||
const limit = limits[type]
|
||||
if (limit === 0) return
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const count = await prisma.auditLog.count({
|
||||
where: {
|
||||
orgId,
|
||||
action: `AI_${type.toUpperCase()}`,
|
||||
createdAt: { gte: monthStart },
|
||||
},
|
||||
})
|
||||
if (count >= limit) {
|
||||
throw { code: 'USAGE_LIMIT', message: `本月 AI${type === 'chat' ? '问答' : type === 'review' ? '合同审查' : '案例匹配'}次数已达上限(${limit}次),请升级套餐` }
|
||||
}
|
||||
}
|
||||
|
||||
async function recordUsage(orgId: string, userId: string, type: 'chat' | 'review' | 'case'): Promise<void> {
|
||||
const month = new Date().toISOString().slice(0, 7)
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orgId,
|
||||
userId,
|
||||
action: `AI_${type.toUpperCase()}`,
|
||||
entity: 'AI',
|
||||
entityId: null,
|
||||
detail: { month, type } as any,
|
||||
ip: '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function buildOrgContext(orgId: string): Promise<string> {
|
||||
const [employees, risks] = await Promise.all([
|
||||
prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
}),
|
||||
prisma.riskItem.findMany({
|
||||
where: { orgId, status: 'PENDING' },
|
||||
include: { employee: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const now = new Date()
|
||||
const empSummary = employees.map((e) => {
|
||||
const contract = e.contracts[0]
|
||||
const daysToExpire = contract?.endDate
|
||||
? Math.floor((new Date(contract.endDate).getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
: null
|
||||
const specialStatus: string[] = []
|
||||
if (e.isPregnant) specialStatus.push('孕期/哺乳期')
|
||||
if (e.isInMedicalPeriod) specialStatus.push('医疗期')
|
||||
if (e.isWorkInjured) specialStatus.push('工伤')
|
||||
return `- ${e.name}(${e.department}),入职${e.hireDate.toISOString().slice(0, 10)},${contract ? `合同:${contract.contractType},${contract.endDate ? `到期${contract.endDate.toISOString().slice(0, 10)}(剩余${daysToExpire}天)` : '无固定期限'}` : '未签合同'}${specialStatus.length > 0 ? `,特殊状态:${specialStatus.join('/')}` : ''}`
|
||||
}).join('\n')
|
||||
|
||||
const riskSummary = risks.map((r) => `- ${r.title}(${r.level}):${r.description || '无详细描述'}`).join('\n')
|
||||
|
||||
return `员工列表(${employees.length}人):
|
||||
${empSummary}
|
||||
|
||||
当前风险项(${risks.length}项):
|
||||
${riskSummary}`
|
||||
}
|
||||
|
||||
router.post('/chat', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { messages } = req.body as { messages: { role: 'user' | 'assistant'; content: string }[] }
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 messages 参数' } })
|
||||
}
|
||||
await checkUsageLimit(req.user!.orgId, 'chat')
|
||||
const orgContext = await buildOrgContext(req.user!.orgId)
|
||||
const reply = await chat(messages, orgContext)
|
||||
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
|
||||
res.json({ success: true, data: { reply } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/chat-stream', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { messages } = req.body as { messages: { role: 'user' | 'assistant'; content: string }[] }
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 messages 参数' } })
|
||||
}
|
||||
await checkUsageLimit(req.user!.orgId, 'chat')
|
||||
const orgContext = await buildOrgContext(req.user!.orgId)
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
let usageRecorded = false
|
||||
try {
|
||||
for await (const delta of chatStream(messages, orgContext)) {
|
||||
res.write(`data: ${JSON.stringify({ delta })}\n\n`)
|
||||
}
|
||||
res.write('data: [DONE]\n\n')
|
||||
} finally {
|
||||
if (!usageRecorded) {
|
||||
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
|
||||
usageRecorded = true
|
||||
}
|
||||
}
|
||||
res.end()
|
||||
} catch (err) {
|
||||
if (!res.headersSent) next(err)
|
||||
else res.end()
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/review', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { contractText } = req.body as { contractText: string }
|
||||
if (!contractText) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少合同文本' } })
|
||||
}
|
||||
await checkUsageLimit(req.user!.orgId, 'review')
|
||||
const result = await reviewContract(contractText)
|
||||
await recordUsage(req.user!.orgId, req.user!.id, 'review')
|
||||
res.json({ success: true, data: { text: result.text, structured: result.structured } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/match-case', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { scenario } = req.body as { scenario: string }
|
||||
if (!scenario) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少争议情形描述' } })
|
||||
}
|
||||
await checkUsageLimit(req.user!.orgId, 'case')
|
||||
const result = await matchCase(scenario)
|
||||
await recordUsage(req.user!.orgId, req.user!.id, 'case')
|
||||
res.json({ success: true, data: { result } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 案例匹配结果转待办(RiskItem)
|
||||
router.post('/case-to-todo', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const schema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
level: z.enum(['HIGH', 'MEDIUM', 'LOW']).default('MEDIUM'),
|
||||
type: z.enum(['CONTRACT', 'SALARY', 'TERMINATION', 'MONTHLY', 'ONBOARDING']).default('TERMINATION'),
|
||||
})
|
||||
const data = schema.parse(req.body)
|
||||
const risk = await prisma.riskItem.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: data.employeeId,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
level: data.level,
|
||||
type: data.type,
|
||||
status: 'PENDING',
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: risk })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const department = req.query.department as string
|
||||
const employeeId = req.query.employeeId as string
|
||||
const riskType = req.query.riskType as string
|
||||
|
||||
let orgContext = await buildOrgContext(req.user!.orgId)
|
||||
|
||||
if (employeeId) {
|
||||
const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
|
||||
if (emp) {
|
||||
const contract = emp.contracts[0]
|
||||
orgContext = `员工详情:
|
||||
- 姓名:${emp.name}
|
||||
- 部门:${emp.department}
|
||||
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}
|
||||
- 状态:${emp.status}
|
||||
- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'}
|
||||
- 合同:${contract ? `${contract.contractType},${contract.startDate.toISOString().slice(0, 10)}至${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}` : '未签合同'}\n${orgContext}`
|
||||
}
|
||||
} else if (department) {
|
||||
const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
|
||||
const empSummary = employees.map(e => `- ${e.name},入职${e.hireDate.toISOString().slice(0, 10)},${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n')
|
||||
orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}\n\n${orgContext}`
|
||||
}
|
||||
|
||||
if (riskType && riskType !== 'all') {
|
||||
orgContext = `请重点关注【${riskType === 'contract' ? '合同' : riskType === 'salary' ? '薪酬' : riskType === 'termination' ? '解聘' : riskType}】类风险。\n\n${orgContext}`
|
||||
}
|
||||
|
||||
const result = await predictRisks(orgContext)
|
||||
res.json({ success: true, data: { result } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== AI 会话历史 ==========
|
||||
|
||||
router.get('/conversations', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const conversations = await prisma.aIConversation.findMany({
|
||||
where: { orgId: req.user!.orgId, userId: req.user!.id },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 50,
|
||||
select: { id: true, title: true, createdAt: true, updatedAt: true },
|
||||
})
|
||||
res.json({ success: true, data: conversations })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const conv = await prisma.aIConversation.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
|
||||
})
|
||||
if (!conv) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
|
||||
res.json({ success: true, data: conv })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/conversations', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { title, messages } = req.body as { title?: string; messages: any[] }
|
||||
const conv = await prisma.aIConversation.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
userId: req.user!.id,
|
||||
title: title || (messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'),
|
||||
messages: messages || [],
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: conv })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { title, messages } = req.body as { title?: string; messages?: any[] }
|
||||
const conv = await prisma.aIConversation.updateMany({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
|
||||
data: {
|
||||
...(title ? { title } : {}),
|
||||
...(messages ? { messages } : {}),
|
||||
},
|
||||
})
|
||||
if (conv.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const conv = await prisma.aIConversation.deleteMany({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
|
||||
})
|
||||
if (conv.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== AI 审查记录保存到员工档案 ==========
|
||||
|
||||
router.post('/review/save', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const schema = z.object({
|
||||
employeeId: z.string(),
|
||||
type: z.enum(['REVIEW', 'CASE']),
|
||||
input: z.string(),
|
||||
result: z.string(),
|
||||
})
|
||||
const data = schema.parse(req.body)
|
||||
const record = await prisma.aIReviewRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: data.employeeId,
|
||||
type: data.type,
|
||||
input: data.input,
|
||||
result: data.result,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/review/employee/:employeeId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.aIReviewRecord.findMany({
|
||||
where: { orgId: req.user!.orgId, employeeId: req.params.employeeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// RAG 知识库管理
|
||||
router.post('/rag/seed', authMiddleware, async (_req: AuthRequest, res, next) => {
|
||||
try {
|
||||
await seedKnowledgeBase()
|
||||
res.json({ success: true, data: { message: '知识库初始化完成' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/rag/add', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { title, content, source, category } = req.body
|
||||
if (!title || !content) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 title 或 content' } })
|
||||
}
|
||||
const result = await addKnowledge(title, content, source || '自定义', category || '其他')
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/rag/search', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { query, topK } = req.body
|
||||
if (!query) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 query' } })
|
||||
}
|
||||
const results = await searchKnowledge(query, topK || 5)
|
||||
res.json({ success: true, data: { results } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 知识库列表
|
||||
router.get('/rag/list', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
await ensureRAGTable()
|
||||
const category = req.query.category as string | undefined
|
||||
const items = category
|
||||
? await prisma.$queryRaw`SELECT id, title, content, source, category, created_at FROM rag_knowledge WHERE category = ${category} ORDER BY created_at DESC LIMIT 200` as any[]
|
||||
: await prisma.$queryRaw`SELECT id, title, content, source, category, created_at FROM rag_knowledge ORDER BY created_at DESC LIMIT 200` as any[]
|
||||
res.json({ success: true, data: items })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除知识条目
|
||||
router.delete('/rag/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
await ensureRAGTable()
|
||||
await prisma.$executeRaw`DELETE FROM rag_knowledge WHERE id = ${req.params.id}`
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
// 获取员工附件列表
|
||||
router.get('/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const attachments = await prisma.employeeAttachment.findMany({
|
||||
where: { orgId: req.user!.orgId, employeeId: req.params.employeeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: attachments })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 添加附件记录(文件URL由前端上传后传入)
|
||||
const attachmentSchema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
fileName: z.string().min(1),
|
||||
fileType: z.enum(['ID_CARD', 'BANK_CARD', 'CONTRACT_SCAN', 'EDUCATION', 'OTHER']),
|
||||
fileUrl: z.string().min(1),
|
||||
fileSize: z.number().int().default(0),
|
||||
})
|
||||
|
||||
router.post('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = attachmentSchema.parse(req.body)
|
||||
const attachment = await prisma.employeeAttachment.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
...data,
|
||||
uploadedBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: attachment })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除附件
|
||||
router.delete('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const attachment = await prisma.employeeAttachment.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!attachment) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '附件不存在' } })
|
||||
}
|
||||
await prisma.employeeAttachment.delete({ where: { id: attachment.id } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Router } from 'express'
|
||||
import { registerSchema, loginSchema, refreshSchema, resetPasswordSchema, forgotPasswordSchema, verifyCodeSchema } from '../schemas/auth.schema'
|
||||
import { register, login, refresh, resetPassword } from '../services/auth.service'
|
||||
import { authLimiter, loginLimiter } from '../middleware/rateLimit'
|
||||
import prisma from '../lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
|
||||
router.post('/register', authLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = registerSchema.parse(req.body)
|
||||
const result = await register(data.orgName, data.phone, data.password)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/login', loginLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = loginSchema.parse(req.body)
|
||||
const result = await login(data.phone, data.password)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/refresh', async (req, res, next) => {
|
||||
try {
|
||||
const data = refreshSchema.parse(req.body)
|
||||
const result = await refresh(data.refreshToken)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 发送重置验证码
|
||||
router.post('/forgot-password/send-code', authLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = forgotPasswordSchema.parse(req.body)
|
||||
const user = await prisma.user.findUnique({ where: { phone: data.phone } })
|
||||
if (!user) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未注册' } })
|
||||
}
|
||||
const code = Math.random().toString().slice(2, 8)
|
||||
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
|
||||
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 验证码重置密码
|
||||
router.post('/forgot-password/verify', authLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = verifyCodeSchema.parse(req.body)
|
||||
const stored = codeStore.get(data.phone)
|
||||
if (!stored || stored.expiresAt < Date.now()) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||||
}
|
||||
if (stored.code !== data.code) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: '验证码错误' } })
|
||||
}
|
||||
codeStore.delete(data.phone)
|
||||
const passwordHash = await bcrypt.hash(data.newPassword, 10)
|
||||
await prisma.user.updateMany({
|
||||
where: { phone: data.phone },
|
||||
data: { passwordHash },
|
||||
})
|
||||
res.json({ success: true, data: { message: '密码重置成功' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/reset-password', authLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = resetPasswordSchema.parse(req.body)
|
||||
const result = await resetPassword(data.phone, data.newPassword)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { getDashboardData } from '../services/risk.service'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = await getDashboardData(req.user!.orgId)
|
||||
res.json({ success: true, data })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 标记待办为已完成
|
||||
router.patch('/todos/:id/resolve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const item = await prisma.riskItem.updateMany({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
|
||||
})
|
||||
if (item.count === 0) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '待办不存在或已处理' } })
|
||||
}
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 忽略待办
|
||||
router.patch('/todos/:id/ignore', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const item = await prisma.riskItem.updateMany({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId, status: 'PENDING' },
|
||||
data: { status: 'IGNORED', resolvedAt: new Date(), resolvedBy: req.user!.id },
|
||||
})
|
||||
if (item.count === 0) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '待办不存在或已处理' } })
|
||||
}
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 批量标记待办为已完成
|
||||
router.patch('/todos/batch-resolve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schema = z.object({ ids: z.array(z.string()) })
|
||||
const { ids } = schema.parse(req.body)
|
||||
const result = await prisma.riskItem.updateMany({
|
||||
where: { id: { in: ids }, orgId: req.user!.orgId, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
|
||||
})
|
||||
res.json({ success: true, data: { count: result.count } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 批量忽略待办
|
||||
router.patch('/todos/batch-ignore', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const schema = z.object({ ids: z.array(z.string()) })
|
||||
const { ids } = schema.parse(req.body)
|
||||
const result = await prisma.riskItem.updateMany({
|
||||
where: { id: { in: ids }, orgId: req.user!.orgId, status: 'PENDING' },
|
||||
data: { status: 'IGNORED', resolvedAt: new Date(), resolvedBy: req.user!.id },
|
||||
})
|
||||
res.json({ success: true, data: { count: result.count } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import prisma from '../lib/prisma'
|
||||
import {
|
||||
createEmployeeSchema,
|
||||
updateEmployeeSchema,
|
||||
batchRenewSchema,
|
||||
addContractSchema,
|
||||
} from '../schemas/contract.schema'
|
||||
import {
|
||||
getEmployees,
|
||||
getEmployeeDetail,
|
||||
createEmployee,
|
||||
rehireEmployee,
|
||||
updateEmployee,
|
||||
deleteEmployee,
|
||||
batchRenew,
|
||||
addContract,
|
||||
} from '../services/contract.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await getEmployees(req.user!.orgId, {
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
pageSize: parseInt(req.query.pageSize as string) || 20,
|
||||
search: req.query.search as string,
|
||||
department: req.query.department as string,
|
||||
})
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employee = await getEmployeeDetail(req.user!.orgId, req.params.id)
|
||||
res.json({ success: true, data: employee })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createEmployeeSchema.parse(req.body)
|
||||
const result = await createEmployee(req.user!.orgId, req.user!.id, data)
|
||||
await auditLog(req, 'CREATE', 'EMPLOYEE', result.id, { name: data.name })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = updateEmployeeSchema.parse(req.body)
|
||||
const result = await updateEmployee(req.user!.orgId, req.params.id, data)
|
||||
await auditLog(req, 'UPDATE', 'EMPLOYEE', req.params.id, data)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/rehire', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await rehireEmployee(req.user!.orgId, req.user!.id, req.params.id, req.body)
|
||||
await auditLog(req, 'REHIRE', 'EMPLOYEE', req.params.id, { hireDate: req.body.hireDate })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'CONFLICT') {
|
||||
return res.status(409).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
if (err?.code === 'VALIDATION_ERROR') {
|
||||
return res.status(400).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await deleteEmployee(req.user!.orgId, req.params.id)
|
||||
await auditLog(req, 'DELETE', 'EMPLOYEE', req.params.id)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 批量续签合规预检
|
||||
router.post('/contracts/preview-renew', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { contractIds } = req.body as { contractIds: string[] }
|
||||
if (!contractIds || !Array.isArray(contractIds) || contractIds.length === 0) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 contractIds' } })
|
||||
}
|
||||
|
||||
const contracts = await prisma.laborContract.findMany({
|
||||
where: { id: { in: contractIds }, orgId: req.user!.orgId },
|
||||
include: { employee: true },
|
||||
orderBy: { startDate: 'asc' },
|
||||
})
|
||||
|
||||
if (contracts.length === 0) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到符合条件的合同' } })
|
||||
}
|
||||
|
||||
// 合规检查:按员工分组,检查历史固定期合同次数
|
||||
const results = []
|
||||
for (const contract of contracts) {
|
||||
const employee = contract.employee
|
||||
|
||||
// 查找该员工所有历史固定期合同(按时间正序,用于判断续签次数)
|
||||
const allFixedContracts = await prisma.laborContract.findMany({
|
||||
where: {
|
||||
employeeId: contract.employeeId,
|
||||
orgId: req.user!.orgId,
|
||||
contractType: 'FIXED',
|
||||
},
|
||||
orderBy: { startDate: 'asc' },
|
||||
})
|
||||
|
||||
// 当前合同是第几次固定期(从1开始计数)
|
||||
const currentIndex = allFixedContracts.findIndex((c) => c.id === contract.id)
|
||||
const renewalCount = currentIndex + 1
|
||||
|
||||
// 判断是否应签无固定期限:
|
||||
// 1. 已连续签订2次以上固定期限合同(第3次应签无固定期限)
|
||||
// 2. 员工连续工作满10年
|
||||
const shouldBeUnfixed = renewalCount >= 2
|
||||
const yearsSinceHire = (Date.now() - new Date(employee.hireDate).getTime()) / (365.25 * 24 * 60 * 60 * 1000)
|
||||
const shouldBeUnfixedByTenure = yearsSinceHire >= 10
|
||||
|
||||
let warning: string | null = null
|
||||
let suggestion: string | null = null
|
||||
|
||||
if (shouldBeUnfixed || shouldBeUnfixedByTenure) {
|
||||
warning = shouldBeUnfixed
|
||||
? `该员工已有 ${renewalCount} 次固定期限合同续签记录(《劳动合同法》第14条),第三次续签应订立无固定期限劳动合同`
|
||||
: `该员工在本公司连续工作 ${Math.floor(yearsSinceHire)} 年(《劳动合同法》第14条),应订立无固定期限劳动合同`
|
||||
suggestion = '建议与员工协商订立无固定期限劳动合同,以规避法律风险'
|
||||
} else {
|
||||
suggestion = `可续签固定期限(当前为第 ${renewalCount} 次续签)`
|
||||
}
|
||||
|
||||
results.push({
|
||||
contractId: contract.id,
|
||||
employeeId: contract.employeeId,
|
||||
employeeName: employee.name,
|
||||
department: employee.department,
|
||||
currentContractType: contract.contractType,
|
||||
renewalCount,
|
||||
yearsSinceHire: Math.floor(yearsSinceHire * 10) / 10,
|
||||
warning,
|
||||
suggestion,
|
||||
canRenewFixed: !warning,
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
total: results.length,
|
||||
warnings: results.filter((r) => r.warning).length,
|
||||
results,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/contracts/batch-renew', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = batchRenewSchema.parse(req.body)
|
||||
const result = await batchRenew(req.user!.orgId, req.user!.id, data.contractIds, data.years)
|
||||
await auditLog(req, 'BATCH_RENEW', 'CONTRACT', undefined, { count: data.contractIds.length })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/contracts', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = addContractSchema.parse(req.body)
|
||||
const result = await addContract(req.user!.orgId, req.user!.id, data)
|
||||
await auditLog(req, 'ADD_CONTRACT', 'CONTRACT', result.id, { employeeId: data.employeeId })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,243 @@
|
||||
import { Router, Response } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import ExcelJS from 'exceljs'
|
||||
import { createGzip } from 'zlib'
|
||||
import { Writable } from 'stream'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 敏感字段脱敏
|
||||
function maskIdCard(idCard: string | null): string | null {
|
||||
if (!idCard) return null
|
||||
if (idCard.length >= 11) return idCard.slice(0, 3) + '*'.repeat(idCard.length - 7) + idCard.slice(-4)
|
||||
return idCard
|
||||
}
|
||||
function maskBankAccount(account: string | null): string | null {
|
||||
if (!account) return null
|
||||
if (account.length > 4) return '*'.repeat(account.length - 4) + account.slice(-4)
|
||||
return account
|
||||
}
|
||||
|
||||
// 导出全部数据(支持模块选择、格式选择、脱敏)
|
||||
router.get('/all', authMiddleware, async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const format = (req.query.format as string) || 'json'
|
||||
const mask = req.query.mask === 'true' || req.user!.role !== 'ADMIN'
|
||||
const modules = (req.query.modules as string || 'employees,contracts,terminations,payrollBatches,payslips,socialRecords,housingRecords,riskItems').split(',')
|
||||
|
||||
const fetchMap: Record<string, () => Promise<any>> = {
|
||||
employees: () => prisma.employee.findMany({ where: { orgId } }),
|
||||
contracts: () => prisma.laborContract.findMany({ where: { orgId } }),
|
||||
terminations: () => prisma.terminationRecord.findMany({ where: { orgId } }),
|
||||
payrollBatches: () => prisma.payrollBatch.findMany({ where: { orgId } }),
|
||||
payslips: () => prisma.payslip.findMany({ where: { orgId } }),
|
||||
socialRecords: () => prisma.employeeSocialInsRecord.findMany({ where: { orgId } }),
|
||||
housingRecords: () => prisma.employeeHousingFundRecord.findMany({ where: { orgId } }),
|
||||
riskItems: () => prisma.riskItem.findMany({ where: { orgId } }),
|
||||
}
|
||||
|
||||
const useGzip = req.query.gzip !== 'false'
|
||||
const batchSize = 500
|
||||
|
||||
if (format === 'excel') {
|
||||
const data: any = { exportedAt: new Date().toISOString(), orgId }
|
||||
|
||||
if (modules.includes('employees')) {
|
||||
const employees = await fetchMap.employees()
|
||||
data.employees = employees.map((e: any) => {
|
||||
let salary = 0
|
||||
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
|
||||
let idCard: string | null = null
|
||||
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
|
||||
let bankAccount: string | null = null
|
||||
try { if (e.bankAccount) bankAccount = decrypt(e.bankAccount) } catch { bankAccount = e.bankAccount }
|
||||
if (mask) {
|
||||
idCard = maskIdCard(idCard)
|
||||
bankAccount = maskBankAccount(bankAccount)
|
||||
if (salary) salary = 0
|
||||
}
|
||||
return { ...e, monthlySalary: salary, idCardNumber: idCard, bankAccount }
|
||||
})
|
||||
}
|
||||
|
||||
for (const mod of modules) {
|
||||
if (mod === 'employees') continue
|
||||
if (fetchMap[mod]) {
|
||||
data[mod] = await fetchMap[mod]()
|
||||
}
|
||||
}
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
for (const mod of modules) {
|
||||
if (!data[mod] || !data[mod].length) continue
|
||||
const ws = workbook.addWorksheet(mod.slice(0, 31))
|
||||
const rows = data[mod]
|
||||
const keys = Object.keys(rows[0]).filter(k => typeof rows[0][k] !== 'object')
|
||||
ws.columns = keys.map(k => ({ header: k, key: k, width: 18 }))
|
||||
ws.getRow(1).font = { bold: true }
|
||||
for (const row of rows) {
|
||||
const flat: any = {}
|
||||
for (const k of keys) flat[k] = typeof row[k] === 'object' ? JSON.stringify(row[k]) : row[k]
|
||||
ws.addRow(flat)
|
||||
}
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.xlsx"`)
|
||||
await workbook.xlsx.write(res)
|
||||
res.end()
|
||||
} else {
|
||||
// JSON 流式导出 + gzip 压缩
|
||||
if (useGzip) {
|
||||
res.setHeader('Content-Encoding', 'gzip')
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json.gz"`)
|
||||
} else {
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json"`)
|
||||
}
|
||||
|
||||
const gzip = useGzip ? createGzip() : null
|
||||
const output: Writable = gzip || res
|
||||
if (gzip) { gzip.pipe(res) }
|
||||
|
||||
const write = (chunk: string) => {
|
||||
output.write(Buffer.from(chunk))
|
||||
}
|
||||
|
||||
write('{"exportedAt":"' + new Date().toISOString() + '","orgId":"' + orgId + '"')
|
||||
|
||||
for (const mod of modules) {
|
||||
write(',"' + mod + '":[')
|
||||
|
||||
if (mod === 'employees') {
|
||||
// 员工数据分批查询,避免内存溢出
|
||||
let skip = 0
|
||||
let first = true
|
||||
while (true) {
|
||||
const batch = await prisma.employee.findMany({ where: { orgId }, skip, take: batchSize })
|
||||
if (batch.length === 0) break
|
||||
for (const e of batch) {
|
||||
let salary = 0
|
||||
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
|
||||
let idCard: string | null = null
|
||||
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
|
||||
let bankAccount: string | null = null
|
||||
try { if (e.bankAccount) bankAccount = decrypt(e.bankAccount) } catch { bankAccount = e.bankAccount }
|
||||
if (mask) {
|
||||
idCard = maskIdCard(idCard)
|
||||
bankAccount = maskBankAccount(bankAccount)
|
||||
if (salary) salary = 0
|
||||
}
|
||||
const row = { ...e, monthlySalary: salary, idCardNumber: idCard, bankAccount }
|
||||
write((first ? '' : ',') + JSON.stringify(row))
|
||||
first = false
|
||||
}
|
||||
skip += batchSize
|
||||
if (batch.length < batchSize) break
|
||||
}
|
||||
} else if (fetchMap[mod]) {
|
||||
const rows = await fetchMap[mod]()
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
write((i === 0 ? '' : ',') + JSON.stringify(rows[i]))
|
||||
}
|
||||
}
|
||||
|
||||
write(']')
|
||||
}
|
||||
|
||||
write('}')
|
||||
if (gzip) gzip.end()
|
||||
else res.end()
|
||||
}
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 导出本月薪税汇总 Excel
|
||||
router.get('/payroll', authMiddleware, async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
|
||||
const entries = await prisma.batchEntry.findMany({
|
||||
where: { orgId, batch: { month, status: 'ARCHIVED' } },
|
||||
include: { employee: true, batch: true },
|
||||
orderBy: { employee: { name: 'asc' } },
|
||||
})
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const ws = workbook.addWorksheet('薪税汇总')
|
||||
|
||||
ws.columns = [
|
||||
{ header: '员工姓名', key: 'name', width: 12 },
|
||||
{ header: '部门', key: 'department', width: 15 },
|
||||
{ header: '基本工资', key: 'baseSalary', width: 12 },
|
||||
{ header: '加班费', key: 'overtimePay', width: 12 },
|
||||
{ header: '津贴补贴', key: 'allowance', width: 12 },
|
||||
{ header: '奖金', key: 'bonus', width: 12 },
|
||||
{ header: '扣款', key: 'deduction', width: 12 },
|
||||
{ header: '应发合计', key: 'totalPay', width: 12 },
|
||||
{ header: '个人社保', key: 'socialEmp', width: 12 },
|
||||
{ header: '个人公积金', key: 'housingEmp', width: 12 },
|
||||
{ header: '个人所得税', key: 'tax', width: 12 },
|
||||
{ header: '实发工资', key: 'netPay', width: 12 },
|
||||
{ header: '企业社保', key: 'socialOrg', width: 12 },
|
||||
{ header: '企业公积金', key: 'housingOrg', width: 12 },
|
||||
{ header: '企业总成本', key: 'orgCost', width: 12 },
|
||||
]
|
||||
|
||||
ws.getRow(1).font = { bold: true }
|
||||
|
||||
for (const e of entries) {
|
||||
ws.addRow({
|
||||
name: e.employee.name,
|
||||
department: e.employee.department,
|
||||
baseSalary: e.baseSalary,
|
||||
overtimePay: e.overtimePay,
|
||||
allowance: e.allowance,
|
||||
bonus: e.bonus,
|
||||
deduction: e.deduction,
|
||||
totalPay: e.totalPay,
|
||||
socialEmp: e.socialEmp,
|
||||
housingEmp: e.housingEmp,
|
||||
tax: e.tax,
|
||||
netPay: e.netPay,
|
||||
socialOrg: e.socialOrg,
|
||||
housingOrg: e.housingOrg,
|
||||
orgCost: e.totalPay + e.socialOrg + e.housingOrg,
|
||||
})
|
||||
}
|
||||
|
||||
// 汇总行
|
||||
const totalRow = ws.addRow({
|
||||
name: '合计',
|
||||
baseSalary: { formula: `SUM(C2:C${entries.length + 1})` },
|
||||
overtimePay: { formula: `SUM(D2:D${entries.length + 1})` },
|
||||
allowance: { formula: `SUM(E2:E${entries.length + 1})` },
|
||||
bonus: { formula: `SUM(F2:F${entries.length + 1})` },
|
||||
deduction: { formula: `SUM(G2:G${entries.length + 1})` },
|
||||
totalPay: { formula: `SUM(H2:H${entries.length + 1})` },
|
||||
socialEmp: { formula: `SUM(I2:I${entries.length + 1})` },
|
||||
housingEmp: { formula: `SUM(J2:J${entries.length + 1})` },
|
||||
tax: { formula: `SUM(K2:K${entries.length + 1})` },
|
||||
netPay: { formula: `SUM(L2:L${entries.length + 1})` },
|
||||
socialOrg: { formula: `SUM(M2:M${entries.length + 1})` },
|
||||
housingOrg: { formula: `SUM(N2:N${entries.length + 1})` },
|
||||
orgCost: { formula: `SUM(O2:O${entries.length + 1})` },
|
||||
})
|
||||
totalRow.font = { bold: true }
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="payroll-${month}.xlsx"`)
|
||||
await workbook.xlsx.write(res)
|
||||
res.end()
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,608 @@
|
||||
import { Router, Response } from 'express'
|
||||
import multer from 'multer'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { encrypt, decrypt, sha256 } from '../lib/crypto'
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
const router = Router()
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
|
||||
|
||||
// 身份证号格式校验(18位正则 + 校验位算法)
|
||||
function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; error?: string } {
|
||||
if (!idCard) return { valid: true }
|
||||
const s = idCard.trim()
|
||||
// 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位)' }
|
||||
}
|
||||
// 校验位算法
|
||||
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
|
||||
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: true }
|
||||
}
|
||||
|
||||
function upgrade15To18(s15: string): string {
|
||||
const born = '19' + s15.substring(6, 12)
|
||||
const body = s15.substring(0, 6) + born + s15.substring(12)
|
||||
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
|
||||
const sum = body.split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
|
||||
return body + checkCodes[sum % 11]
|
||||
}
|
||||
|
||||
// 社保基数范围校验
|
||||
const SOCIAL_INS_LIMITS: Record<string, { min: number; max: number }> = {
|
||||
'北京': { min: 6326, max: 33891 },
|
||||
'上海': { min: 7310, max: 36549 },
|
||||
'广州': { min: 5284, max: 27501 },
|
||||
'深圳': { min: 3523, max: 27501 },
|
||||
'杭州': { min: 4812, max: 24060 },
|
||||
}
|
||||
function validateSocialBase(base: number, city?: string): { valid: boolean; warning?: string } {
|
||||
if (!city || !SOCIAL_INS_LIMITS[city]) return { valid: true }
|
||||
const limits = SOCIAL_INS_LIMITS[city]
|
||||
if (base < limits.min) return { valid: true, warning: `基数${base}低于${city}下限${limits.min}` }
|
||||
if (base > limits.max) return { valid: true, warning: `基数${base}高于${city}上限${limits.max}` }
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
function dateToMonth(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function parseDate(v: any): Date | null {
|
||||
if (!v) return null
|
||||
if (v instanceof Date) return v
|
||||
if (typeof v === 'number') {
|
||||
const d = XLSX.SSF.parse_date_code(v)
|
||||
if (d) return new Date(d.y, d.m - 1, d.d)
|
||||
}
|
||||
const s = String(v).trim()
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return new Date(s)
|
||||
if (/^\d{4}\/\d{2}\/\d{2}/.test(s)) return new Date(s.replace(/\//g, '-'))
|
||||
return null
|
||||
}
|
||||
|
||||
function val(v: any): string {
|
||||
if (v == null) return ''
|
||||
return String(v).trim()
|
||||
}
|
||||
|
||||
function num(v: any): number {
|
||||
const n = Number(v)
|
||||
return isNaN(n) ? 0 : n
|
||||
}
|
||||
|
||||
// ========== 导入预览(不写入数据库) ==========
|
||||
|
||||
router.post('/excel/preview', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
|
||||
|
||||
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
|
||||
const preview: any = { employees: [], contracts: [], overtime: [], disciplinary: [], attendance: [], errors: [] as any[] }
|
||||
|
||||
const empSheet = wb.Sheets['员工信息']
|
||||
if (empSheet) {
|
||||
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(r['姓名']), department: val(r['部门']) || '未分配', hireDate: r['入职日期'], salary: num(r['月工资']), phone: val(r['手机号']), idCard: val(r['身份证号']), status: 'normal', errors: [] as string[], warnings: [] as string[] }
|
||||
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
|
||||
const hireDate = parseDate(r['入职日期'])
|
||||
if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') }
|
||||
if (row.salary === 0) { row.status = 'error'; row.errors.push('月工资为空') }
|
||||
if (row.idCard) {
|
||||
const idCheck = validateIdCard(row.idCard)
|
||||
if (!idCheck.valid) { row.status = row.status === 'normal' ? 'warning' : row.status; row.warnings.push(idCheck.error!) }
|
||||
if (idCheck.upgraded) { row.idCard = idCheck.upgraded; row.warnings.push('15位身份证已升级为18位') }
|
||||
}
|
||||
if (row.status === 'error') preview.errors.push({ sheet: '员工信息', row: i + 2, name: row.name, errors: row.errors })
|
||||
preview.employees.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
const contractSheet = wb.Sheets['劳动合同']
|
||||
if (contractSheet) {
|
||||
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(r['姓名']), idCard: val(r['身份证号']), contractType: val(r['合同类型']), startDate: r['合同开始日期'], endDate: r['合同结束日期'], status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
const sd = parseDate(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 })
|
||||
preview.contracts.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
const otSheet = wb.Sheets['加班记录']
|
||||
if (otSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(otSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const otType = val(r['加班类型']) || '工作日加班'
|
||||
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], hours: num(r['加班时长']), otType, status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
const dt = parseDate(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 })
|
||||
preview.overtime.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
const discSheet = wb.Sheets['违纪记录']
|
||||
if (discSheet) {
|
||||
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(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], violationType: val(r['违纪类型']), description: val(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)
|
||||
}
|
||||
}
|
||||
|
||||
const attSheet = wb.Sheets['考勤记录']
|
||||
if (attSheet) {
|
||||
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(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], attStatus: val(r['考勤状态']), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
const dt = parseDate(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 })
|
||||
preview.attendance.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
totalRows: preview.employees.length + preview.contracts.length + preview.overtime.length + preview.disciplinary.length + preview.attendance.length,
|
||||
normalRows: 0,
|
||||
warningRows: 0,
|
||||
errorRows: preview.errors.length,
|
||||
sheets: Object.keys(wb.Sheets).filter(s => !s.startsWith('!')),
|
||||
}
|
||||
summary.normalRows = summary.totalRows - summary.errorRows
|
||||
preview.summary = summary
|
||||
|
||||
res.json({ success: true, data: preview })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 错误日志导出 ==========
|
||||
|
||||
router.post('/excel/error-log', authMiddleware, async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
const { errors } = req.body as { errors: any[] }
|
||||
if (!errors || !errors.length) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '无错误数据' } })
|
||||
}
|
||||
const data = errors.map(e => ({
|
||||
'Sheet': e.sheet || '',
|
||||
'行号': e.row || '',
|
||||
'员工姓名': e.name || '',
|
||||
'错误类型': Array.isArray(e.errors) ? e.errors.join('; ') : (e.error || ''),
|
||||
}))
|
||||
const ws = XLSX.utils.json_to_sheet(data)
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, '错误日志')
|
||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="import-errors-${Date.now()}.xlsx"`)
|
||||
res.send(buf)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
|
||||
const orgId = req.user!.orgId
|
||||
const userId = req.user!.id
|
||||
|
||||
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
|
||||
const result: any = { employees: 0, contracts: 0, overtime: 0, disciplinary: 0, attendance: 0, errors: [] as string[] }
|
||||
|
||||
const empSheet = wb.Sheets['员工信息']
|
||||
if (empSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(empSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const name = val(r['姓名'])
|
||||
if (!name) { result.errors.push(`员工第${i + 2}行:姓名为空,跳过`); continue }
|
||||
const dept = val(r['部门']) || '未分配'
|
||||
const hireDate = parseDate(r['入职日期'])
|
||||
if (!hireDate) { result.errors.push(`员工第${i + 2}行:入职日期格式错误`); continue }
|
||||
const salary = String(num(r['月工资']))
|
||||
if (salary === '0') { result.errors.push(`员工第${i + 2}行:月工资为空`); continue }
|
||||
|
||||
let idCard = val(r['身份证号'])
|
||||
if (idCard) {
|
||||
const idCheck = validateIdCard(idCard)
|
||||
if (!idCheck.valid) { result.errors.push(`员工第${i + 2}行:${idCheck.error}`); continue }
|
||||
if (idCheck.upgraded) idCard = idCheck.upgraded
|
||||
}
|
||||
|
||||
const emp = await prisma.employee.create({
|
||||
data: {
|
||||
orgId, name, department: dept, hireDate,
|
||||
monthlySalary: encrypt(salary),
|
||||
gender: val(r['性别']) || null,
|
||||
phone: val(r['手机号']) || null,
|
||||
idCardNumber: idCard ? encrypt(idCard) : null,
|
||||
idCardHash: idCard ? sha256(idCard) : null,
|
||||
emergencyContact: val(r['紧急联系人']) || null,
|
||||
emergencyPhone: val(r['紧急联系电话']) || null,
|
||||
address: val(r['住址']) || null,
|
||||
bankName: val(r['开户行']) || null,
|
||||
bankAccount: val(r['银行账号']) ? encrypt(val(r['银行账号'])) : null,
|
||||
socialInsBase: num(r['社保基数']) || num(salary),
|
||||
housingFundBase: num(r['公积金基数']) || num(salary),
|
||||
specialDeduction: num(r['专项附加扣除']) || 0,
|
||||
isPregnant: val(r['孕期']) === '是',
|
||||
isInMedicalPeriod: val(r['医疗期']) === '是',
|
||||
isWorkInjured: val(r['工伤']) === '是',
|
||||
socialInsStartMonth: dateToMonth(hireDate),
|
||||
housingFundStartMonth: dateToMonth(hireDate),
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(r['社保基数']) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
|
||||
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(r['公积金基数']) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
|
||||
await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary: 0, newSalary: num(salary), effectiveDate: hireDate, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } })
|
||||
await prisma.employeeDepartmentRecord.create({ data: { orgId, employeeId: emp.id, oldDepartment: '', newDepartment: dept, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } })
|
||||
result.employees++
|
||||
} catch (e: any) {
|
||||
result.errors.push(`员工第${i + 2}行:${e?.message || '导入失败'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contractSheet = wb.Sheets['劳动合同']
|
||||
if (contractSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(contractSheet)
|
||||
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
|
||||
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
|
||||
const empByName = new Map(employees.map(e => [e.name, e.id]))
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const idCard = val(r['身份证号'])
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
|
||||
if (!empId) { result.errors.push(`合同第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const startDate = parseDate(r['合同开始日期'])
|
||||
if (!startDate) { result.errors.push(`合同第${i + 2}行:开始日期格式错误`); continue }
|
||||
const typeMap: any = { '固定期限': 'FIXED', '无固定期限': 'UNFIXED', '未签': 'UNSIGNED' }
|
||||
const contractType = typeMap[val(r['合同类型'])] || 'FIXED'
|
||||
if (contractType !== 'UNSIGNED') {
|
||||
await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId, employeeId: empId,
|
||||
signDate: parseDate(r['签订日期']) || null,
|
||||
startDate,
|
||||
endDate: parseDate(r['合同结束日期']) || null,
|
||||
contractType,
|
||||
signMethod: val(r['签订方式']) === '电子' ? 'ELECTRONIC' : 'PAPER',
|
||||
contractYears: num(r['合同年限']) || 3,
|
||||
probationMonths: num(r['试用期月数']) || 0,
|
||||
probationSalary: num(r['试用期工资']) || 0,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
result.contracts++
|
||||
}
|
||||
} catch (e: any) {
|
||||
result.errors.push(`合同第${i + 2}行:${e?.message || '导入失败'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const otSheet = wb.Sheets['加班记录']
|
||||
if (otSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(otSheet)
|
||||
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
|
||||
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
|
||||
const empByName = new Map(employees.map(e => [e.name, e.id]))
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const idCard = val(r['身份证号'])
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
|
||||
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const date = parseDate(r['日期'])
|
||||
if (!date) continue
|
||||
const month = dateToMonth(date)
|
||||
const otType = val(r['加班类型']) || '工作日加班'
|
||||
const hours = num(r['加班时长'])
|
||||
const weekdayHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0)
|
||||
const weekendHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0)
|
||||
const holidayHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0)
|
||||
await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours, weekendHours, holidayHours, createdBy: userId } as any })
|
||||
result.overtime++
|
||||
}
|
||||
}
|
||||
|
||||
const discSheet = wb.Sheets['违纪记录']
|
||||
if (discSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(discSheet)
|
||||
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
|
||||
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
|
||||
const empByName = new Map(employees.map(e => [e.name, e.id]))
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const idCard = val(r['身份证号'])
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
|
||||
if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const date = parseDate(r['日期'])
|
||||
if (!date) continue
|
||||
const typeMap: any = { '迟到': 'LATE', '旷工': 'ABSENT', '不服从': 'INSUBORDINATION', '违纪': 'MISCONDUCT', '违规': 'VIOLATE_POLICY', '其他': 'OTHER' }
|
||||
const sevMap: any = { '警告': 'WARNING', '严重': 'SERIOUS', '重度': 'SEVERE' }
|
||||
const actMap: any = { '口头警告': 'ORAL_WARNING', '书面警告': 'WRITTEN_WARNING', '扣款': 'DEDUCTION', '降级': 'DEMOTION', '辞退': 'TERMINATION' }
|
||||
await prisma.disciplinaryRecord.create({ data: { orgId, employeeId: empId, violationDate: date, violationType: typeMap[val(r['违纪类型'])] || 'OTHER', description: val(r['描述']), severity: sevMap[val(r['严重程度'])] || 'WARNING', action: actMap[val(r['处罚'])] || 'ORAL_WARNING', createdBy: userId } })
|
||||
result.disciplinary++
|
||||
}
|
||||
}
|
||||
|
||||
const attSheet = wb.Sheets['考勤记录']
|
||||
if (attSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(attSheet)
|
||||
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
|
||||
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
|
||||
const empByName = new Map(employees.map(e => [e.name, e.id]))
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const idCard = val(r['身份证号'])
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
|
||||
if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const date = parseDate(r['日期'])
|
||||
if (!date) continue
|
||||
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
|
||||
await prisma.attendanceRecord.create({ data: { orgId, employeeId: empId, date, status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId } })
|
||||
result.attendance++
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
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', '孕期': '否', '医疗期': '否', '工伤': '否' },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(empData), '员工信息')
|
||||
|
||||
const contractData = [
|
||||
{ '姓名': '张三', '身份证号': '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, '是否审批': '是' },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
|
||||
|
||||
const discData = [
|
||||
{ '姓名': '张三', '身份证号': '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', '备注': '' },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
|
||||
|
||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="import-template.xlsx"')
|
||||
res.send(buf)
|
||||
})
|
||||
|
||||
// ========== 月度导入 ==========
|
||||
|
||||
router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
|
||||
const orgId = req.user!.orgId
|
||||
const userId = req.user!.id
|
||||
const month = val(req.body.month) || dateToMonth(new Date())
|
||||
if (!/^\d{4}-\d{2}$/.test(month)) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '月份格式应为 YYYY-MM' } })
|
||||
}
|
||||
|
||||
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
|
||||
const result: any = { month, attendance: 0, overtime: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } }
|
||||
|
||||
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, monthlySalary: true, department: true, idCardHash: true } })
|
||||
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e]))
|
||||
const empByName = new Map(employees.map(e => [e.name, e]))
|
||||
|
||||
function findEmp(r: any) {
|
||||
const idCard = val(r['身份证号'])
|
||||
if (idCard) {
|
||||
const emp = empByHash.get(sha256(idCard))
|
||||
if (emp) return emp
|
||||
}
|
||||
return empByName.get(val(r['姓名']))
|
||||
}
|
||||
|
||||
// 考勤记录
|
||||
const attSheet = wb.Sheets['考勤记录']
|
||||
if (attSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(attSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const emp = findEmp(r)
|
||||
if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const date = parseDate(r['日期'])
|
||||
if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue }
|
||||
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
|
||||
await prisma.attendanceRecord.upsert({
|
||||
where: { employeeId_date: { employeeId: emp.id, date } },
|
||||
create: { orgId, employeeId: emp.id, date, status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId },
|
||||
update: { status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null },
|
||||
})
|
||||
result.attendance++
|
||||
} catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) }
|
||||
}
|
||||
}
|
||||
|
||||
// 加班记录
|
||||
const otSheet = wb.Sheets['加班记录']
|
||||
if (otSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(otSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const emp = findEmp(r)
|
||||
if (!emp) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const date = parseDate(r['日期'])
|
||||
if (!date) { result.errors.push(`加班第${i + 2}行:日期格式错误`); continue }
|
||||
const otMonth = dateToMonth(date)
|
||||
const hours = num(r['加班时长'])
|
||||
const otType = val(r['加班类型']) || '工作日加班'
|
||||
const wdHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0)
|
||||
const weHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0)
|
||||
const hoHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0)
|
||||
await prisma.overtimeRecord.upsert({
|
||||
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
|
||||
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours } as any,
|
||||
update: {
|
||||
weekdayHours: { increment: wdHours },
|
||||
weekendHours: { increment: weHours },
|
||||
holidayHours: { increment: hoHours },
|
||||
},
|
||||
})
|
||||
result.overtime++
|
||||
} catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) }
|
||||
}
|
||||
}
|
||||
|
||||
// 薪资调整
|
||||
const salarySheet = wb.Sheets['薪资调整']
|
||||
if (salarySheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(salarySheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const emp = findEmp(r)
|
||||
if (!emp) { result.errors.push(`薪资第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const newSalary = num(r['调整后月薪'])
|
||||
if (newSalary <= 0) { result.errors.push(`薪资第${i + 2}行:调整后月薪无效`); continue }
|
||||
const effDate = parseDate(r['生效日期']) || new Date(month + '-01')
|
||||
const effMonth = dateToMonth(effDate)
|
||||
let oldSalary = 0
|
||||
try { oldSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { oldSalary = 0 }
|
||||
// 关闭之前有效记录
|
||||
await prisma.salaryChangeRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: effMonth } })
|
||||
await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary, newSalary, effectiveDate: effDate, effectiveMonth: effMonth, endMonth: null, changeType: 'SALARY_CHANGE', reason: val(r['调薪原因']) || '月度导入', createdBy: userId } })
|
||||
await prisma.employee.update({ where: { id: emp.id }, data: { monthlySalary: encrypt(String(newSalary)) } })
|
||||
result.salaryChanges++
|
||||
} catch (e: any) { result.errors.push(`薪资第${i + 2}行:${e?.message || '导入失败'}`) }
|
||||
}
|
||||
}
|
||||
|
||||
// 社保增减员
|
||||
const socialSheet = wb.Sheets['社保变动']
|
||||
if (socialSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(socialSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const emp = findEmp(r)
|
||||
if (!emp) { result.errors.push(`社保第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const changeType = val(r['变动类型'])
|
||||
const base = num(r['缴费基数'])
|
||||
const city = val(r['城市']) || '北京'
|
||||
if (changeType === '增员' || changeType === '调基') {
|
||||
const baseCheck = validateSocialBase(base, city)
|
||||
if (baseCheck.warning) result.errors.push(`社保第${i + 2}行警告:${baseCheck.warning}`)
|
||||
// 关闭之前有效记录
|
||||
await prisma.employeeSocialInsRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month } })
|
||||
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: month, endMonth: null, base: base || 0, changeType: changeType === '增员' ? 'ONBOARDING' : 'ADJUST', createdBy: userId } })
|
||||
await prisma.employee.update({ where: { id: emp.id }, data: { socialInsBase: base || 0, socialInsStartMonth: month, socialInsEndMonth: null } })
|
||||
} else if (changeType === '减员') {
|
||||
await prisma.employeeSocialInsRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month, changeType: 'TERMINATION' } })
|
||||
await prisma.employee.update({ where: { id: emp.id }, data: { socialInsEndMonth: month } })
|
||||
}
|
||||
result.socialInsChanges++
|
||||
} catch (e: any) { result.errors.push(`社保第${i + 2}行:${e?.message || '导入失败'}`) }
|
||||
}
|
||||
}
|
||||
|
||||
// 公积金增减员
|
||||
const hfSheet = wb.Sheets['公积金变动']
|
||||
if (hfSheet) {
|
||||
const rows = XLSX.utils.sheet_to_json(hfSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const emp = findEmp(r)
|
||||
if (!emp) { result.errors.push(`公积金第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const changeType = val(r['变动类型'])
|
||||
const base = num(r['缴费基数'])
|
||||
if (changeType === '增员' || changeType === '调基') {
|
||||
await prisma.employeeHousingFundRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month } })
|
||||
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: month, endMonth: null, base: base || 0, changeType: changeType === '增员' ? 'ONBOARDING' : 'ADJUST', createdBy: userId } })
|
||||
await prisma.employee.update({ where: { id: emp.id }, data: { housingFundBase: base || 0, housingFundStartMonth: month, housingFundEndMonth: null } })
|
||||
} else if (changeType === '减员') {
|
||||
await prisma.employeeHousingFundRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month, changeType: 'TERMINATION' } })
|
||||
await prisma.employee.update({ where: { id: emp.id }, data: { housingFundEndMonth: month } })
|
||||
}
|
||||
result.housingFundChanges++
|
||||
} catch (e: any) { result.errors.push(`公积金第${i + 2}行:${e?.message || '导入失败'}`) }
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: Response) => {
|
||||
const wb = XLSX.utils.book_new()
|
||||
|
||||
const attData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-01', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
|
||||
|
||||
const otData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班时长': 2, '加班类型': '工作日加班' }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
|
||||
|
||||
const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(salaryData), '薪资调整')
|
||||
|
||||
const socialData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(socialData), '社保变动')
|
||||
|
||||
const hfData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(hfData), '公积金变动')
|
||||
|
||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="monthly-import-template.xlsx"')
|
||||
res.send(buf)
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
// 获取通知设置
|
||||
router.get('/settings', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let setting = await prisma.notificationSetting.findUnique({
|
||||
where: { orgId: req.user!.orgId },
|
||||
})
|
||||
if (!setting) {
|
||||
setting = await prisma.notificationSetting.create({
|
||||
data: { orgId: req.user!.orgId },
|
||||
})
|
||||
}
|
||||
res.json({ success: true, data: setting })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新通知设置
|
||||
const settingSchema = z.object({
|
||||
contractExpiry: z.boolean().optional(),
|
||||
expiryDays: z.number().int().min(1).max(365).optional(),
|
||||
contractUnsigned: z.boolean().optional(),
|
||||
overtimeAlert: z.boolean().optional(),
|
||||
payslipReady: z.boolean().optional(),
|
||||
payrollDay: z.number().int().min(1).max(28).optional(),
|
||||
socialInsDay: z.number().int().min(1).max(28).optional(),
|
||||
housingFundDay: z.number().int().min(1).max(28).optional(),
|
||||
taxDay: z.number().int().min(1).max(28).optional(),
|
||||
wechatWebhook: z.string().url().nullable().optional(),
|
||||
emailNotify: z.boolean().optional(),
|
||||
email: z.string().email().nullable().optional(),
|
||||
})
|
||||
|
||||
router.put('/settings', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = settingSchema.parse(req.body)
|
||||
const setting = await prisma.notificationSetting.upsert({
|
||||
where: { orgId: req.user!.orgId },
|
||||
update: data,
|
||||
create: { orgId: req.user!.orgId, ...data },
|
||||
})
|
||||
res.json({ success: true, data: setting })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取通知列表
|
||||
router.get('/logs', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.notificationLog.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.notificationLog.count({ where: { orgId: req.user!.orgId } }),
|
||||
])
|
||||
res.json({ success: true, data: { items: logs, total, page, pageSize, totalPages: Math.ceil(total / pageSize) } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 手动触发合同到期检查
|
||||
router.post('/check-contracts', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const setting = await prisma.notificationSetting.findUnique({
|
||||
where: { orgId: req.user!.orgId },
|
||||
})
|
||||
const expiryDays = setting?.expiryDays || 30
|
||||
const now = new Date()
|
||||
const threshold = new Date(now.getTime() + expiryDays * 24 * 60 * 60 * 1000)
|
||||
|
||||
const contracts = await prisma.laborContract.findMany({
|
||||
where: {
|
||||
orgId: req.user!.orgId,
|
||||
endDate: { lte: threshold, gte: now },
|
||||
},
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
})
|
||||
|
||||
const logs: any[] = []
|
||||
for (const contract of contracts) {
|
||||
const daysLeft = Math.ceil((contract.endDate!.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const title = `${contract.employee.name}的合同将在${daysLeft}天后到期`
|
||||
const content = `员工 ${contract.employee.name}(${contract.employee.department})的合同将于 ${contract.endDate!.toISOString().slice(0, 10)} 到期,请及时处理续签或终止事宜。`
|
||||
|
||||
const log = await prisma.notificationLog.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
type: 'CONTRACT_EXPIRY',
|
||||
title,
|
||||
content,
|
||||
channel: 'IN_APP',
|
||||
employeeId: contract.employeeId,
|
||||
},
|
||||
})
|
||||
logs.push(log)
|
||||
|
||||
if (setting?.wechatWebhook) {
|
||||
try {
|
||||
await fetch(setting.wechatWebhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
msgtype: 'text',
|
||||
text: { content: `【合同到期提醒】${title}\n${content}` },
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
// webhook 发送失败不阻断流程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { checked: contracts.length, notified: logs.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试通知渠道
|
||||
router.post('/test', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { channel } = req.body as { channel: 'wechat' | 'email' }
|
||||
const setting = await prisma.notificationSetting.findUnique({ where: { orgId: req.user!.orgId } })
|
||||
if (!setting) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '通知设置不存在' } })
|
||||
|
||||
if (channel === 'wechat') {
|
||||
if (!setting.wechatWebhook) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置企业微信 Webhook' } })
|
||||
try {
|
||||
const resp = await fetch(setting.wechatWebhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ msgtype: 'text', text: { content: '【测试消息】通知渠道连接正常,配置有效。' } }),
|
||||
})
|
||||
const data = await resp.json() as any
|
||||
if (data.errcode && data.errcode !== 0) {
|
||||
return res.json({ success: false, error: { code: 'TEST_FAILED', message: `Webhook 返回错误: ${data.errmsg || data.errcode}` } })
|
||||
}
|
||||
res.json({ success: true, data: { message: '测试消息已发送到企业微信' } })
|
||||
} catch (e: any) {
|
||||
res.json({ success: false, error: { code: 'TEST_FAILED', message: `发送失败: ${e?.message || '网络错误'}` } })
|
||||
}
|
||||
} else if (channel === 'email') {
|
||||
if (!setting.email) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置通知邮箱' } })
|
||||
// 邮件发送(开发阶段仅返回成功)
|
||||
res.json({ success: true, data: { message: `测试邮件已发送到 ${setting.email}` } })
|
||||
} else {
|
||||
res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不支持的通知渠道' } })
|
||||
}
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,577 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
// ========== 加班费记录 ==========
|
||||
|
||||
const overtimeSchema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
monthlyWage: z.number().positive(),
|
||||
weekdayHours: z.number().min(0).default(0),
|
||||
weekendHours: z.number().min(0).default(0),
|
||||
holidayHours: z.number().min(0).default(0),
|
||||
})
|
||||
|
||||
// 获取加班费记录列表
|
||||
router.get('/overtime', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { employeeId, month } = req.query
|
||||
const records = await prisma.overtimeRecord.findMany({
|
||||
where: {
|
||||
orgId: req.user!.orgId,
|
||||
...(employeeId ? { employeeId: String(employeeId) } : {}),
|
||||
...(month ? { month: String(month) } : {}),
|
||||
},
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 保存加班费记录
|
||||
router.post('/overtime', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = overtimeSchema.parse(req.body)
|
||||
const hourlyWage = data.monthlyWage / 21.75 / 8
|
||||
const weekdayPay = hourlyWage * 1.5 * data.weekdayHours
|
||||
const weekendPay = hourlyWage * 2.0 * data.weekendHours
|
||||
const holidayPay = hourlyWage * 3.0 * data.holidayHours
|
||||
const totalPay = weekdayPay + weekendPay + holidayPay
|
||||
|
||||
const record = await prisma.overtimeRecord.upsert({
|
||||
where: {
|
||||
employeeId_month: { employeeId: data.employeeId, month: data.month },
|
||||
},
|
||||
update: {
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
weekdayPay,
|
||||
weekendPay,
|
||||
holidayPay,
|
||||
totalPay,
|
||||
},
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: data.employeeId,
|
||||
month: data.month,
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
weekdayPay,
|
||||
weekendPay,
|
||||
holidayPay,
|
||||
totalPay,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新加班记录(按ID)
|
||||
const overtimeUpdateSchema = z.object({
|
||||
weekdayHours: z.number().min(0).optional(),
|
||||
weekendHours: z.number().min(0).optional(),
|
||||
holidayHours: z.number().min(0).optional(),
|
||||
monthlyWage: z.number().positive().optional(),
|
||||
})
|
||||
|
||||
router.put('/overtime/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const data = overtimeUpdateSchema.parse(req.body)
|
||||
|
||||
const existing = await prisma.overtimeRecord.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
res.status(404).json({ success: false, message: '记录不存在' })
|
||||
return
|
||||
}
|
||||
|
||||
const monthlyWage = data.monthlyWage ?? 0
|
||||
const weekdayHours = data.weekdayHours ?? existing.weekdayHours
|
||||
const weekendHours = data.weekendHours ?? existing.weekendHours
|
||||
const holidayHours = data.holidayHours ?? existing.holidayHours
|
||||
|
||||
const hourlyWage = monthlyWage / 21.75 / 8
|
||||
const weekdayPay = hourlyWage * 1.5 * weekdayHours
|
||||
const weekendPay = hourlyWage * 2.0 * weekendHours
|
||||
const holidayPay = hourlyWage * 3.0 * holidayHours
|
||||
const totalPay = weekdayPay + weekendPay + holidayPay
|
||||
|
||||
const record = await prisma.overtimeRecord.update({
|
||||
where: { id },
|
||||
data: {
|
||||
weekdayHours,
|
||||
weekendHours,
|
||||
holidayHours,
|
||||
weekdayPay,
|
||||
weekendPay,
|
||||
holidayPay,
|
||||
totalPay,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 工资条管理 ==========
|
||||
|
||||
const payslipSchema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
baseSalary: z.number().min(0).default(0),
|
||||
overtimePay: z.number().min(0).default(0),
|
||||
weekdayOvertimePay: z.number().min(0).default(0),
|
||||
weekendOvertimePay: z.number().min(0).default(0),
|
||||
holidayOvertimePay: z.number().min(0).default(0),
|
||||
allowance: z.number().min(0).default(0),
|
||||
deduction: z.number().min(0).default(0),
|
||||
})
|
||||
|
||||
// 获取工资条列表
|
||||
router.get('/payslip', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, employeeId } = req.query
|
||||
const payslips = await prisma.payslip.findMany({
|
||||
where: {
|
||||
orgId: req.user!.orgId,
|
||||
...(month ? { month: String(month) } : {}),
|
||||
...(employeeId ? { employeeId: String(employeeId) } : {}),
|
||||
},
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
orderBy: [{ month: 'desc' }, { employee: { name: 'asc' } }],
|
||||
})
|
||||
res.json({ success: true, data: payslips })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 创建/更新工资条
|
||||
router.post('/payslip', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = payslipSchema.parse(req.body)
|
||||
const totalPay = data.baseSalary + data.overtimePay + data.allowance - data.deduction
|
||||
|
||||
const payslip = await prisma.payslip.upsert({
|
||||
where: {
|
||||
employeeId_month: { employeeId: data.employeeId, month: data.month },
|
||||
},
|
||||
update: {
|
||||
baseSalary: data.baseSalary,
|
||||
overtimePay: data.overtimePay,
|
||||
weekdayOvertimePay: data.weekdayOvertimePay,
|
||||
weekendOvertimePay: data.weekendOvertimePay,
|
||||
holidayOvertimePay: data.holidayOvertimePay,
|
||||
allowance: data.allowance,
|
||||
deduction: data.deduction,
|
||||
totalPay,
|
||||
},
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: data.employeeId,
|
||||
month: data.month,
|
||||
baseSalary: data.baseSalary,
|
||||
overtimePay: data.overtimePay,
|
||||
weekdayOvertimePay: data.weekdayOvertimePay,
|
||||
weekendOvertimePay: data.weekendOvertimePay,
|
||||
holidayOvertimePay: data.holidayOvertimePay,
|
||||
allowance: data.allowance,
|
||||
deduction: data.deduction,
|
||||
totalPay,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: payslip })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 从加班费记录自动生成工资条
|
||||
router.post('/payslip/generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, employeeId, baseSalary, allowance, deduction } = req.body as {
|
||||
month: string
|
||||
employeeId: string
|
||||
baseSalary: number
|
||||
allowance?: number
|
||||
deduction?: number
|
||||
}
|
||||
|
||||
const overtime = await prisma.overtimeRecord.findUnique({
|
||||
where: { employeeId_month: { employeeId, month } },
|
||||
})
|
||||
|
||||
const overtimePay = overtime?.totalPay || 0
|
||||
const totalPay = baseSalary + overtimePay + (allowance || 0) - (deduction || 0)
|
||||
|
||||
const payslip = await prisma.payslip.upsert({
|
||||
where: { employeeId_month: { employeeId, month } },
|
||||
update: {
|
||||
baseSalary,
|
||||
overtimePay,
|
||||
weekdayOvertimePay: overtime?.weekdayPay || 0,
|
||||
weekendOvertimePay: overtime?.weekendPay || 0,
|
||||
holidayOvertimePay: overtime?.holidayPay || 0,
|
||||
allowance: allowance || 0,
|
||||
deduction: deduction || 0,
|
||||
totalPay,
|
||||
},
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId,
|
||||
month,
|
||||
baseSalary,
|
||||
overtimePay,
|
||||
weekdayOvertimePay: overtime?.weekdayPay || 0,
|
||||
weekendOvertimePay: overtime?.weekendPay || 0,
|
||||
holidayOvertimePay: overtime?.holidayPay || 0,
|
||||
allowance: allowance || 0,
|
||||
deduction: deduction || 0,
|
||||
totalPay,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: payslip })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除工资条
|
||||
router.delete('/payslip/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await prisma.payslip.delete({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 批量生成工资条 ==========
|
||||
|
||||
const batchGenerateSchema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
allowances: z.record(z.string(), z.number().default(0)).optional(),
|
||||
deductions: z.record(z.string(), z.number().default(0)).optional(),
|
||||
})
|
||||
|
||||
// 批量生成全员工资条
|
||||
router.post('/payslip/batch-generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, allowances = {}, deductions = {} } = batchGenerateSchema.parse(req.body)
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
const results: any[] = []
|
||||
for (const emp of employees) {
|
||||
const overtime = await prisma.overtimeRecord.findUnique({
|
||||
where: { employeeId_month: { employeeId: emp.id, month } },
|
||||
})
|
||||
|
||||
const overtimePay = overtime?.totalPay || 0
|
||||
const allowance = allowances[emp.id] || 0
|
||||
const deduction = deductions[emp.id] || 0
|
||||
|
||||
let baseSalary = 0
|
||||
if (emp.contracts[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
|
||||
baseSalary = emp.contracts[0].probationSalary
|
||||
} else if (emp.monthlySalary) {
|
||||
try {
|
||||
baseSalary = Number(decrypt(emp.monthlySalary)) || 0
|
||||
} catch {
|
||||
baseSalary = Number(emp.monthlySalary) || 0
|
||||
}
|
||||
}
|
||||
|
||||
const totalPay = baseSalary + overtimePay + allowance - deduction
|
||||
|
||||
const payslip = await prisma.payslip.upsert({
|
||||
where: { employeeId_month: { employeeId: emp.id, month } },
|
||||
update: { baseSalary, overtimePay, allowance, deduction, totalPay },
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: emp.id,
|
||||
month,
|
||||
baseSalary,
|
||||
overtimePay,
|
||||
weekdayOvertimePay: overtime?.weekdayPay || 0,
|
||||
weekendOvertimePay: overtime?.weekendPay || 0,
|
||||
holidayOvertimePay: overtime?.holidayPay || 0,
|
||||
allowance,
|
||||
deduction,
|
||||
totalPay,
|
||||
},
|
||||
})
|
||||
results.push(payslip)
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { generated: results.length, payslips: results } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 加班费计算规则配置 ==========
|
||||
|
||||
const overtimeConfigSchema = z.object({
|
||||
weekdayRate: z.number().min(1).default(1.5),
|
||||
weekendRate: z.number().min(1).default(2.0),
|
||||
holidayRate: z.number().min(1).default(3.0),
|
||||
monthlyDays: z.number().min(1).default(21.75),
|
||||
dailyHours: z.number().min(1).default(8),
|
||||
})
|
||||
|
||||
// 获取加班费计算规则
|
||||
router.get('/overtime/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let config = await prisma.overtimeConfig.findUnique({ where: { orgId: req.user!.orgId } })
|
||||
if (!config) {
|
||||
config = await prisma.overtimeConfig.create({ data: { orgId: req.user!.orgId } })
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 保存加班费计算规则
|
||||
router.post('/overtime/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = overtimeConfigSchema.parse(req.body)
|
||||
const config = await prisma.overtimeConfig.upsert({
|
||||
where: { orgId: req.user!.orgId },
|
||||
update: data,
|
||||
create: { orgId: req.user!.orgId, ...data },
|
||||
})
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 批量导入加班工时 ==========
|
||||
|
||||
const batchOvertimeSchema = z.array(
|
||||
z.object({
|
||||
employeeId: z.string().min(1),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
weekdayHours: z.number().min(0).default(0),
|
||||
weekendHours: z.number().min(0).default(0),
|
||||
holidayHours: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const items = batchOvertimeSchema.parse(req.body)
|
||||
const results: any[] = []
|
||||
|
||||
for (const data of items) {
|
||||
const record = await prisma.overtimeRecord.upsert({
|
||||
where: { employeeId_month: { employeeId: data.employeeId, month: data.month } },
|
||||
update: {
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
weekdayPay: 0, weekendPay: 0, holidayPay: 0, totalPay: 0,
|
||||
},
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: data.employeeId,
|
||||
month: data.month,
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
},
|
||||
})
|
||||
results.push(record)
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { imported: results.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 批次导入加班费 ==========
|
||||
|
||||
router.post('/overtime/import-to-batch/:batchId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, message: '批次不存在' })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, message: '已归档批次不可操作' })
|
||||
|
||||
// 获取加班费计算规则
|
||||
let config = await prisma.overtimeConfig.findUnique({ where: { orgId } })
|
||||
if (!config) config = await prisma.overtimeConfig.create({ data: { orgId } })
|
||||
|
||||
// 获取该月未关联批次的加班记录
|
||||
const overtimeRecords = await prisma.overtimeRecord.findMany({
|
||||
where: { orgId, month: batch.month, batchId: null },
|
||||
include: { employee: { select: { id: true, name: true, monthlySalary: true } } },
|
||||
})
|
||||
|
||||
if (overtimeRecords.length === 0) {
|
||||
return res.json({ success: false, message: '没有可导入的加班记录(所有记录已关联批次或无数据)' })
|
||||
}
|
||||
|
||||
const results: any[] = []
|
||||
for (const ot of overtimeRecords) {
|
||||
// 获取员工月工资
|
||||
let monthlyWage = 0
|
||||
try {
|
||||
monthlyWage = ot.employee.monthlySalary ? Number(decrypt(ot.employee.monthlySalary)) : 0
|
||||
} catch {
|
||||
monthlyWage = Number(ot.employee.monthlySalary) || 0
|
||||
}
|
||||
if (!monthlyWage) continue
|
||||
|
||||
// 根据规则计算加班费
|
||||
const hourlyWage = monthlyWage / config.monthlyDays / config.dailyHours
|
||||
const weekdayPay = hourlyWage * config.weekdayRate * ot.weekdayHours
|
||||
const weekendPay = hourlyWage * config.weekendRate * ot.weekendHours
|
||||
const holidayPay = hourlyWage * config.holidayRate * ot.holidayHours
|
||||
const totalPay = weekdayPay + weekendPay + holidayPay
|
||||
|
||||
// 更新加班记录:计算金额并锁定到批次
|
||||
await prisma.overtimeRecord.update({
|
||||
where: { id: ot.id },
|
||||
data: { weekdayPay, weekendPay, holidayPay, totalPay, batchId },
|
||||
})
|
||||
|
||||
// 更新批次条目的加班费
|
||||
const entry = await prisma.batchEntry.findUnique({
|
||||
where: { batchId_employeeId: { batchId, employeeId: ot.employeeId } },
|
||||
})
|
||||
if (entry) {
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { overtimePay: totalPay },
|
||||
})
|
||||
// 重新计算条目
|
||||
const newTotalPay = entry.baseSalary + totalPay + entry.allowance + entry.bonus - entry.deduction
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { totalPay: newTotalPay },
|
||||
})
|
||||
}
|
||||
|
||||
results.push({ employeeId: ot.employeeId, employeeName: ot.employee.name, totalPay })
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { imported: results.length, details: results } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 税率试算 ==========
|
||||
router.post('/tax-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { employeeId, month, baseSalary, overtimePay, allowance, deduction, bonus, specialDeduction } = req.body
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 获取员工和配置
|
||||
const [employee, socialConfig, housingConfig] = await Promise.all([
|
||||
employeeId ? prisma.employee.findFirst({ where: { id: employeeId, orgId } }) : null,
|
||||
prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
}),
|
||||
prisma.housingFundConfig.findFirst({
|
||||
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const emp = employee || { socialInsBase: baseSalary, housingFundBase: baseSalary }
|
||||
const socialBase = emp.socialInsBase || baseSalary
|
||||
const housingBase = emp.housingFundBase || baseSalary
|
||||
|
||||
// 计算社保公积金
|
||||
let socialEmp = 0, housingEmp = 0
|
||||
if (socialConfig) {
|
||||
const { calcSocialInsurance } = await import('../services/payroll.service')
|
||||
const social = calcSocialInsurance(socialBase, socialConfig)
|
||||
socialEmp = social.socialEmp
|
||||
}
|
||||
if (housingConfig) {
|
||||
const { calcHousingFund } = await import('../services/payroll.service')
|
||||
const housing = calcHousingFund(housingBase, housingConfig)
|
||||
housingEmp = housing.housingEmp
|
||||
}
|
||||
|
||||
// 获取 YTD 数据计算累计个税
|
||||
const year = month.slice(0, 4)
|
||||
const ytdPayslips = employeeId
|
||||
? await prisma.payslip.findMany({
|
||||
where: { employeeId, month: { startsWith: year }, status: 'PUBLISHED' },
|
||||
orderBy: { month: 'asc' },
|
||||
})
|
||||
: []
|
||||
|
||||
const ytdTaxableIncome = ytdPayslips.reduce((sum, p) => sum + (p.totalPay - p.deduction - socialEmp - housingEmp - (specialDeduction || 0)), 0)
|
||||
const ytdTaxDeducted = ytdPayslips.reduce((sum, p) => sum + (p.tax || 0), 0)
|
||||
|
||||
const { calcCumulativeTax } = await import('../services/payroll.service')
|
||||
const totalPay = (baseSalary || 0) + (overtimePay || 0) + (allowance || 0) - (deduction || 0) + (bonus || 0)
|
||||
const taxableIncome = totalPay - socialEmp - housingEmp - (specialDeduction || 0)
|
||||
const tax = calcCumulativeTax(ytdTaxableIncome + taxableIncome, ytdTaxDeducted)
|
||||
const netPay = totalPay - socialEmp - housingEmp - tax
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
baseSalary: baseSalary || 0,
|
||||
overtimePay: overtimePay || 0,
|
||||
allowance: allowance || 0,
|
||||
deduction: deduction || 0,
|
||||
bonus: bonus || 0,
|
||||
totalPay,
|
||||
socialEmp,
|
||||
housingEmp,
|
||||
specialDeduction: specialDeduction || 0,
|
||||
taxableIncome,
|
||||
estimatedTax: tax,
|
||||
netPay,
|
||||
ytdPayslipCount: ytdPayslips.length,
|
||||
breakdown: [
|
||||
{ label: '应发合计', value: totalPay },
|
||||
{ label: '个人社保', value: -socialEmp },
|
||||
{ label: '个人公积金', value: -housingEmp },
|
||||
{ label: '专项附加扣除', value: -(specialDeduction || 0) },
|
||||
{ label: '应纳税所得额', value: taxableIncome },
|
||||
{ label: '当月个税', value: -tax },
|
||||
{ label: '实发工资', value: netPay },
|
||||
],
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,673 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import {
|
||||
getTemplate,
|
||||
calcBatchEntry,
|
||||
getPayrollRiskWarnings,
|
||||
generatePayslipFromBatches,
|
||||
} from '../services/payroll.service'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
// ========== 薪酬模版 ==========
|
||||
|
||||
// 获取薪酬模版
|
||||
router.get('/template', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const items = await getTemplate(req.user!.orgId)
|
||||
res.json({ success: true, data: items })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新薪酬模版项
|
||||
const updateTemplateItemSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
formula: z.string().nullable().optional(),
|
||||
order: z.number().int().optional(),
|
||||
isEditable: z.boolean().optional(),
|
||||
})
|
||||
|
||||
router.put('/template/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = updateTemplateItemSchema.parse(req.body)
|
||||
const item = await prisma.payslipItem.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!item) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模版项不存在' } })
|
||||
|
||||
const updateData: any = {}
|
||||
if (data.name !== undefined && !item.isDefault) updateData.name = data.name
|
||||
if (data.formula !== undefined) updateData.formula = data.formula
|
||||
if (data.order !== undefined) updateData.order = data.order
|
||||
if (data.isEditable !== undefined) updateData.isEditable = data.isEditable
|
||||
|
||||
const updated = await prisma.payslipItem.update({ where: { id: req.params.id }, data: updateData })
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 新增薪酬模版项
|
||||
const createTemplateItemSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
code: z.string().min(1),
|
||||
type: z.enum(['INPUT', 'CALCULATED']),
|
||||
formula: z.string().nullable().optional(),
|
||||
order: z.number().int().default(99),
|
||||
isEditable: z.boolean().default(true),
|
||||
})
|
||||
|
||||
router.post('/template', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = createTemplateItemSchema.parse(req.body)
|
||||
const item = await prisma.payslipItem.create({
|
||||
data: { ...data, orgId: req.user!.orgId, isDefault: false },
|
||||
})
|
||||
res.json({ success: true, data: item })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除薪酬模版项(仅非预置项)
|
||||
router.delete('/template/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const item = await prisma.payslipItem.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!item) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模版项不存在' } })
|
||||
if (item.isDefault) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '预置项不可删除' } })
|
||||
|
||||
await prisma.payslipItem.delete({ where: { id: req.params.id } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 发薪批次 ==========
|
||||
|
||||
// 检查本月是否已发薪
|
||||
router.get('/batches/check', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month } = req.query
|
||||
if (!month) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 month 参数' } })
|
||||
|
||||
const archivedBatches = await prisma.payrollBatch.count({
|
||||
where: { orgId: req.user!.orgId, month: String(month), status: 'ARCHIVED' },
|
||||
})
|
||||
const draftBatches = await prisma.payrollBatch.count({
|
||||
where: { orgId: req.user!.orgId, month: String(month), status: 'DRAFT' },
|
||||
})
|
||||
const publishedPayslips = await prisma.payslip.count({
|
||||
where: { orgId: req.user!.orgId, month: String(month), status: 'PUBLISHED' },
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
hasArchivedBatch: archivedBatches > 0,
|
||||
archivedCount: archivedBatches,
|
||||
draftCount: draftBatches,
|
||||
payslipsPublished: publishedPayslips > 0,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取可复制的归档批次列表
|
||||
router.get('/batches/archived/list', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const batches = await prisma.payrollBatch.findMany({
|
||||
where: { orgId, status: 'ARCHIVED' },
|
||||
orderBy: [{ month: 'desc' }, { batchNo: 'desc' }],
|
||||
select: { id: true, name: true, month: true, type: true, employeeCount: true, totalPay: true, totalNetPay: true },
|
||||
take: 20,
|
||||
})
|
||||
res.json({ success: true, data: batches })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取批次列表
|
||||
router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, monthFrom, monthTo, status, type } = req.query
|
||||
const batches = await prisma.payrollBatch.findMany({
|
||||
where: {
|
||||
orgId: req.user!.orgId,
|
||||
...(month ? { month: String(month) } : {}),
|
||||
...(monthFrom ? { month: { gte: String(monthFrom) } } : {}),
|
||||
...(monthTo ? { month: { lte: String(monthTo) } } : {}),
|
||||
...(status ? { status: String(status) as any } : {}),
|
||||
...(type ? { type: String(type) as any } : {}),
|
||||
},
|
||||
orderBy: [{ month: 'desc' }, { batchNo: 'asc' }],
|
||||
})
|
||||
res.json({ success: true, data: batches })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取批次详情
|
||||
router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const batch = await prisma.payrollBatch.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
include: {
|
||||
entries: {
|
||||
include: {
|
||||
employee: { select: { id: true, name: true, department: true, status: true, bankAccount: true, bankName: true } },
|
||||
},
|
||||
orderBy: { employee: { name: 'asc' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
res.json({ success: true, data: batch })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 重命名批次
|
||||
router.put('/batches/:id/name', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name } = req.body
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '批次名称不能为空' } })
|
||||
}
|
||||
const batch = await prisma.payrollBatch.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可重命名' } })
|
||||
}
|
||||
const updated = await prisma.payrollBatch.update({
|
||||
where: { id: req.params.id },
|
||||
data: { name: name.trim() },
|
||||
})
|
||||
res.json({ success: true, data: { id: updated.id, name: updated.name } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 创建批次
|
||||
const createBatchSchema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).default('REGULAR'),
|
||||
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch']).default('copy_last'),
|
||||
sourceBatchId: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
remark: z.string().optional(),
|
||||
})
|
||||
|
||||
router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, type, mode, sourceBatchId, name, remark } = createBatchSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 查询当月已有批次数
|
||||
const existingBatches = await prisma.payrollBatch.count({
|
||||
where: { orgId, month },
|
||||
})
|
||||
const batchNo = existingBatches + 1
|
||||
|
||||
// 获取在职员工 + 本月离职员工
|
||||
const monthStart = new Date(`${month}-01`)
|
||||
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0, 23, 59, 59)
|
||||
|
||||
// 获取上月发薪数据
|
||||
const prevMonth = new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1)
|
||||
const prevMonthStr = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
const batchName = name || `${month} 第${batchNo}批 ${type === 'BONUS' ? '奖金' : type === 'TERMINATION' ? '离职结算' : type === 'SEVERANCE' ? '补偿金' : '发薪'}`
|
||||
|
||||
// 根据模式确定员工列表和数据来源
|
||||
let employees: any[] = []
|
||||
let sourceEntries: any[] | null = null
|
||||
|
||||
if (mode === 'blank_all') {
|
||||
// 全空白:不拉入员工
|
||||
employees = []
|
||||
} else if (mode === 'copy_batch' && sourceBatchId) {
|
||||
// 复制指定批次:从源批次复制条目
|
||||
const sourceBatch = await prisma.payrollBatch.findFirst({
|
||||
where: { id: sourceBatchId, orgId, status: 'ARCHIVED' },
|
||||
include: { entries: true },
|
||||
})
|
||||
if (!sourceBatch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '源批次不存在或未归档' } })
|
||||
sourceEntries = sourceBatch.entries
|
||||
// 提取员工 ID,后续按此创建条目
|
||||
const employeeIds = sourceEntries.map(e => e.employeeId)
|
||||
employees = await prisma.employee.findMany({
|
||||
where: { id: { in: employeeIds }, orgId },
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
})
|
||||
} else {
|
||||
// copy_last 或 blank_employees:拉入员工
|
||||
if (type === 'TERMINATION' || type === 'SEVERANCE') {
|
||||
const terminations = await prisma.terminationRecord.findMany({
|
||||
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
|
||||
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
|
||||
})
|
||||
employees = terminations.map(t => t.employee)
|
||||
} else {
|
||||
employees = await prisma.employee.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
OR: [
|
||||
{ status: 'ACTIVE' },
|
||||
{ status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } },
|
||||
],
|
||||
},
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 创建批次
|
||||
const batch = await prisma.payrollBatch.create({
|
||||
data: {
|
||||
orgId,
|
||||
month,
|
||||
batchNo,
|
||||
name: batchName,
|
||||
type,
|
||||
remark,
|
||||
createdBy: req.user!.id,
|
||||
employeeCount: employees.length,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建批次条目
|
||||
const entries: any[] = []
|
||||
for (const emp of employees) {
|
||||
let baseSalary = 0
|
||||
let overtimePay = 0
|
||||
let allowance = 0
|
||||
let deduction = 0
|
||||
let bonus = 0
|
||||
|
||||
if (mode === 'copy_batch' && sourceEntries) {
|
||||
// 复制指定批次:从源条目复制数据
|
||||
const srcEntry = sourceEntries.find(e => e.employeeId === emp.id)
|
||||
if (srcEntry) {
|
||||
baseSalary = srcEntry.baseSalary
|
||||
overtimePay = srcEntry.overtimePay
|
||||
allowance = srcEntry.allowance
|
||||
deduction = srcEntry.deduction
|
||||
bonus = srcEntry.bonus
|
||||
}
|
||||
} else if (mode === 'copy_last') {
|
||||
// 复制上月:从上月工资条复制
|
||||
const prevPayslip = await prisma.payslip.findUnique({
|
||||
where: { employeeId_month: { employeeId: emp.id, month: prevMonthStr } },
|
||||
})
|
||||
const overtime = await prisma.overtimeRecord.findUnique({
|
||||
where: { employeeId_month: { employeeId: emp.id, month } },
|
||||
})
|
||||
|
||||
if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
|
||||
baseSalary = emp.contracts[0].probationSalary
|
||||
} else if (emp.monthlySalary) {
|
||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||
}
|
||||
if (prevPayslip) baseSalary = prevPayslip.baseSalary
|
||||
overtimePay = overtime?.totalPay || 0
|
||||
allowance = prevPayslip?.allowance || 0
|
||||
deduction = prevPayslip?.deduction || 0
|
||||
}
|
||||
// blank_employees 和 blank_all: 所有金额默认 0
|
||||
|
||||
// 判断同月是否已有归档的常规批次(用于决定是否跳过社保)
|
||||
const hasArchivedRegularBatch = await prisma.payrollBatch.count({
|
||||
where: { orgId, month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
|
||||
})
|
||||
|
||||
// 计算社保、个税等
|
||||
// 同月已有归档常规批次时,新批次跳过社保(避免重复扣缴),但用户可手动编辑覆盖
|
||||
const skipSocial = type !== 'BONUS' && type !== 'SEVERANCE' && hasArchivedRegularBatch > 0
|
||||
const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { skipSocial })
|
||||
|
||||
// 风险提示
|
||||
const riskWarnings = await getPayrollRiskWarnings(orgId, emp.id)
|
||||
|
||||
const entry = await prisma.batchEntry.create({
|
||||
data: {
|
||||
batchId: batch.id,
|
||||
orgId,
|
||||
employeeId: emp.id,
|
||||
baseSalary,
|
||||
overtimePay,
|
||||
allowance,
|
||||
deduction,
|
||||
bonus,
|
||||
socialEmp: calcResult.socialEmp,
|
||||
socialOrg: calcResult.socialOrg,
|
||||
housingEmp: calcResult.housingEmp,
|
||||
housingOrg: calcResult.housingOrg,
|
||||
tax: calcResult.tax,
|
||||
totalPay: calcResult.totalPay,
|
||||
netPay: calcResult.netPay,
|
||||
riskWarnings,
|
||||
},
|
||||
})
|
||||
entries.push(entry)
|
||||
}
|
||||
|
||||
// 更新批次汇总
|
||||
const totals = entries.reduce((acc, e) => ({
|
||||
totalPay: acc.totalPay + e.totalPay,
|
||||
totalNetPay: acc.totalNetPay + e.netPay,
|
||||
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
|
||||
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
|
||||
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
|
||||
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
|
||||
totalTax: acc.totalTax + e.tax,
|
||||
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
|
||||
|
||||
const updatedBatch = await prisma.payrollBatch.update({
|
||||
where: { id: batch.id },
|
||||
data: {
|
||||
totalPay: Math.round(totals.totalPay * 100) / 100,
|
||||
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
|
||||
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
|
||||
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
|
||||
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
|
||||
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
|
||||
totalTax: Math.round(totals.totalTax * 100) / 100,
|
||||
},
|
||||
include: { entries: { include: { employee: { select: { id: true, name: true, department: true, status: true } } } } },
|
||||
})
|
||||
|
||||
res.json({ success: true, data: updatedBatch })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖)
|
||||
const updateEntrySchema = z.object({
|
||||
baseSalary: z.number().min(0).optional(),
|
||||
overtimePay: z.number().min(0).optional(),
|
||||
allowance: z.number().min(0).optional(),
|
||||
deduction: z.number().min(0).optional(),
|
||||
bonus: z.number().min(0).optional(),
|
||||
socialEmp: z.number().min(0).optional(),
|
||||
socialOrg: z.number().min(0).optional(),
|
||||
housingEmp: z.number().min(0).optional(),
|
||||
housingOrg: z.number().min(0).optional(),
|
||||
})
|
||||
|
||||
router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId, employeeId } = req.params
|
||||
const data = updateEntrySchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可编辑' } })
|
||||
|
||||
const entry = await prisma.batchEntry.findUnique({
|
||||
where: { batchId_employeeId: { batchId, employeeId } },
|
||||
})
|
||||
if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } })
|
||||
|
||||
// 合并输入项
|
||||
const inputs = {
|
||||
baseSalary: data.baseSalary ?? entry.baseSalary,
|
||||
overtimePay: data.overtimePay ?? entry.overtimePay,
|
||||
allowance: data.allowance ?? entry.allowance,
|
||||
deduction: data.deduction ?? entry.deduction,
|
||||
bonus: data.bonus ?? entry.bonus,
|
||||
}
|
||||
|
||||
// 构建社保覆盖参数(如果请求中包含社保字段)
|
||||
const overrideSocial: any = {}
|
||||
if (data.socialEmp !== undefined) overrideSocial.socialEmp = data.socialEmp
|
||||
if (data.socialOrg !== undefined) overrideSocial.socialOrg = data.socialOrg
|
||||
if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp
|
||||
if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg
|
||||
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
|
||||
|
||||
// 重新计算
|
||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
|
||||
|
||||
const updated = await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { ...inputs, ...calcResult },
|
||||
})
|
||||
|
||||
// 更新批次汇总
|
||||
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const totals = allEntries.reduce((acc, e) => ({
|
||||
totalPay: acc.totalPay + (e.id === entry.id ? calcResult.totalPay : e.totalPay),
|
||||
totalNetPay: acc.totalNetPay + (e.id === entry.id ? calcResult.netPay : e.netPay),
|
||||
totalSocialOrg: acc.totalSocialOrg + (e.id === entry.id ? calcResult.socialOrg : e.socialOrg),
|
||||
totalSocialEmp: acc.totalSocialEmp + (e.id === entry.id ? calcResult.socialEmp : e.socialEmp),
|
||||
totalHousingOrg: acc.totalHousingOrg + (e.id === entry.id ? calcResult.housingOrg : e.housingOrg),
|
||||
totalHousingEmp: acc.totalHousingEmp + (e.id === entry.id ? calcResult.housingEmp : e.housingEmp),
|
||||
totalTax: acc.totalTax + (e.id === entry.id ? calcResult.tax : e.tax),
|
||||
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
|
||||
|
||||
await prisma.payrollBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
totalPay: Math.round(totals.totalPay * 100) / 100,
|
||||
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
|
||||
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
|
||||
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
|
||||
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
|
||||
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
|
||||
totalTax: Math.round(totals.totalTax * 100) / 100,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 批次增加人员
|
||||
router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const { employeeIds } = req.body as { employeeIds: string[] }
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可编辑' } })
|
||||
|
||||
const results: any[] = []
|
||||
for (const employeeId of employeeIds) {
|
||||
// 检查是否已在批次中
|
||||
const existing = await prisma.batchEntry.findUnique({
|
||||
where: { batchId_employeeId: { batchId, employeeId } },
|
||||
})
|
||||
if (existing) continue
|
||||
|
||||
const emp = await prisma.employee.findFirst({
|
||||
where: { id: employeeId, orgId },
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
})
|
||||
if (!emp) continue
|
||||
|
||||
let baseSalary = 0
|
||||
if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
|
||||
baseSalary = emp.contracts[0].probationSalary
|
||||
} else if (emp.monthlySalary) {
|
||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||
}
|
||||
|
||||
const overtime = await prisma.overtimeRecord.findUnique({
|
||||
where: { employeeId_month: { employeeId, month: batch.month } },
|
||||
})
|
||||
const overtimePay = overtime?.totalPay || 0
|
||||
|
||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
|
||||
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
|
||||
|
||||
const entry = await prisma.batchEntry.create({
|
||||
data: {
|
||||
batchId, orgId, employeeId,
|
||||
baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0,
|
||||
...calcResult, riskWarnings,
|
||||
},
|
||||
})
|
||||
results.push(entry)
|
||||
}
|
||||
|
||||
// 更新批次人数
|
||||
const count = await prisma.batchEntry.count({ where: { batchId } })
|
||||
await prisma.payrollBatch.update({ where: { id: batchId }, data: { employeeCount: count } })
|
||||
|
||||
res.json({ success: true, data: { added: results.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 批次移除人员
|
||||
router.delete('/batches/:batchId/employees/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId, employeeId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可编辑' } })
|
||||
|
||||
await prisma.batchEntry.deleteMany({ where: { batchId, employeeId } })
|
||||
|
||||
const count = await prisma.batchEntry.count({ where: { batchId } })
|
||||
await prisma.payrollBatch.update({ where: { id: batchId }, data: { employeeCount: count } })
|
||||
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除批次(仅限草稿状态)
|
||||
router.delete('/batches/:batchId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可删除' } })
|
||||
|
||||
await prisma.batchEntry.deleteMany({ where: { batchId } })
|
||||
await prisma.payrollBatch.delete({ where: { id: batchId } })
|
||||
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 归档批次
|
||||
router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '批次已归档' } })
|
||||
|
||||
await prisma.payrollBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'ARCHIVED', archivedAt: new Date() },
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { archived: true } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 从已归档批次汇总生成工资条
|
||||
router.post('/payslips/generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month } = req.body
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
if (!month || !/^\d{4}-\d{2}$/.test(month)) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请提供有效的月份(YYYY-MM)' } })
|
||||
}
|
||||
|
||||
// 检查是否有已归档批次
|
||||
const archivedBatches = await prisma.payrollBatch.count({
|
||||
where: { orgId, month, status: 'ARCHIVED' },
|
||||
})
|
||||
if (archivedBatches === 0) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '当月无已归档批次,无法生成工资条' } })
|
||||
}
|
||||
|
||||
const result = await generatePayslipFromBatches(orgId, month)
|
||||
|
||||
// 自动标记"生成工资条"待办为已完成
|
||||
await prisma.riskItem.updateMany({
|
||||
where: { orgId, status: 'PENDING', type: 'SALARY', title: { startsWith: `${month}月 生成工资条` } },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { generated: result.generated } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 银行代发文件导出(接口预留)
|
||||
router.get('/batches/:batchId/export', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
const { format = 'csv' } = req.query
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({
|
||||
where: { id: batchId, orgId },
|
||||
include: {
|
||||
entries: {
|
||||
include: { employee: { select: { name: true, bankAccount: true, bankName: true } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status !== 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅归档批次可导出' } })
|
||||
|
||||
if (format === 'csv') {
|
||||
const header = '姓名,银行账号,开户行,实发金额\n'
|
||||
const rows = batch.entries.map(e => `${e.employee.name},${e.employee.bankAccount || ''},${e.employee.bankName || ''},${e.netPay}`).join('\n')
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="payroll-${batch.month}-batch${batch.batchNo}.csv"`)
|
||||
return res.send('\ufeff' + header + rows)
|
||||
}
|
||||
|
||||
res.json({ success: true, data: batch })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,425 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import multer from 'multer'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import prisma from '../lib/prisma'
|
||||
import { signAccessToken, verifyAccessToken } from '../lib/jwt'
|
||||
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 验证码临时存储(生产环境应使用 Redis)
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number; failCount: number; lastSentAt: number }>()
|
||||
|
||||
// 员工端认证中间件
|
||||
function portalAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未登录' } })
|
||||
}
|
||||
const token = authHeader.substring(7)
|
||||
try {
|
||||
const payload = verifyAccessToken(token)
|
||||
if (!payload || payload.role !== 'EMPLOYEE') {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '无效的员工端 Token' } })
|
||||
}
|
||||
;(req as any).employee = { id: payload.id, orgId: payload.orgId }
|
||||
next()
|
||||
} catch {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Token 无效或已过期' } })
|
||||
}
|
||||
}
|
||||
|
||||
// 密码登录
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const data = portalLoginSchema.parse(req.body)
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { phone: data.phone, status: 'ACTIVE' },
|
||||
})
|
||||
if (!employee || !employee.passwordHash) {
|
||||
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
|
||||
}
|
||||
const valid = await bcrypt.compare(data.password, employee.passwordHash)
|
||||
if (!valid) {
|
||||
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
|
||||
}
|
||||
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
|
||||
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 发送验证码(页面内显示)
|
||||
router.post('/send-code', async (req, res, next) => {
|
||||
try {
|
||||
const data = portalSendCodeSchema.parse(req.body)
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { phone: data.phone, status: 'ACTIVE' },
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未在系统中登记' } })
|
||||
}
|
||||
// 频率限制:60秒内不可重复发送
|
||||
const existing = codeStore.get(data.phone)
|
||||
if (existing && existing.lastSentAt && Date.now() - existing.lastSentAt < 60 * 1000) {
|
||||
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '验证码发送过于频繁,请60秒后重试' } })
|
||||
}
|
||||
const code = Math.random().toString().slice(2, 8)
|
||||
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
|
||||
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 验证码登录
|
||||
router.post('/verify-code', async (req, res, next) => {
|
||||
try {
|
||||
const data = portalVerifyCodeSchema.parse(req.body)
|
||||
const stored = codeStore.get(data.phone)
|
||||
if (!stored || stored.expiresAt < Date.now()) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||||
}
|
||||
// 错误次数限制:5次后锁定
|
||||
if (stored.failCount >= 5) {
|
||||
codeStore.delete(data.phone)
|
||||
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
|
||||
}
|
||||
if (stored.code !== data.code) {
|
||||
stored.failCount++
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
|
||||
}
|
||||
codeStore.delete(data.phone)
|
||||
const employee = await prisma.employee.findFirst({ where: { phone: data.phone, status: 'ACTIVE' } })
|
||||
if (!employee) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
|
||||
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 工资条
|
||||
router.get('/payslip', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const month = req.query.month as string || new Date().toISOString().slice(0, 7)
|
||||
const payslip = await prisma.payslip.findFirst({
|
||||
where: { employeeId: req.employee.id, orgId: req.employee.orgId, month },
|
||||
})
|
||||
if (!payslip) {
|
||||
return res.json({ success: true, data: null })
|
||||
}
|
||||
res.json({ success: true, data: payslip })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 工资条历史(最近6个月)
|
||||
router.get('/payslip/history', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const payslips = await prisma.payslip.findMany({
|
||||
where: { employeeId: req.employee.id, orgId: req.employee.orgId },
|
||||
orderBy: { month: 'desc' },
|
||||
take: 6,
|
||||
})
|
||||
res.json({ success: true, data: payslips })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 工资条确认已阅
|
||||
router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const payslip = await prisma.payslip.findFirst({
|
||||
where: { id: req.params.id, orgId: req.employee.orgId, employeeId: req.employee.id },
|
||||
include: { employee: true },
|
||||
})
|
||||
if (!payslip) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '工资条不存在' } })
|
||||
}
|
||||
await prisma.payslip.update({
|
||||
where: { id: req.params.id },
|
||||
data: { confirmedAt: new Date(), confirmedIp: req.ip },
|
||||
})
|
||||
// 通知 HR
|
||||
await prisma.notificationLog.create({
|
||||
data: {
|
||||
orgId: req.employee.orgId,
|
||||
title: '工资条确认通知',
|
||||
content: `员工 ${payslip.employee.name} 已确认 ${payslip.month} 月工资条(IP: ${req.ip})`,
|
||||
type: 'PAYSLIP_CONFIRM',
|
||||
channel: 'IN_APP',
|
||||
},
|
||||
})
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 我的合同
|
||||
router.get('/contract', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const contract = await prisma.laborContract.findFirst({
|
||||
where: { employeeId: req.employee.id, orgId: req.employee.orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (!contract) {
|
||||
return res.json({ success: true, data: null })
|
||||
}
|
||||
res.json({ success: true, data: contract })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 入职填报提交
|
||||
router.post('/onboarding', async (req, res, next) => {
|
||||
try {
|
||||
const data = onboardingSchema.parse(req.body)
|
||||
const link = await prisma.onboardingLink.findFirst({
|
||||
where: { token: data.token, status: 'PENDING', expiresAt: { gt: new Date() } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
await prisma.onboardingLink.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
employeeName: data.name,
|
||||
phone: data.phone,
|
||||
formData: {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
idCard: data.idCard,
|
||||
emergencyContact: data.emergencyContact,
|
||||
emergencyPhone: data.emergencyPhone,
|
||||
address: data.address,
|
||||
bankCard: data.bankCard,
|
||||
bankName: data.bankName,
|
||||
},
|
||||
status: 'APPROVED',
|
||||
usedAt: new Date(),
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: { message: '信息提交成功,HR 将审核您的信息' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 合同签署验证码发送
|
||||
router.post('/contract-confirm/send-code', async (req, res, next) => {
|
||||
try {
|
||||
const data = contractSendCodeSchema.parse(req.body)
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||||
include: { contract: { include: { employee: true } } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
const phone = link.contract.employee.phone
|
||||
if (!phone) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NO_PHONE', message: '员工手机号未登记,无法发送验证码' } })
|
||||
}
|
||||
const code = Math.random().toString().slice(2, 8)
|
||||
codeStore.set(`contract-${data.token}`, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
|
||||
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 合同签署确认
|
||||
router.post('/contract-confirm', async (req, res, next) => {
|
||||
try {
|
||||
const data = contractConfirmSchema.parse(req.body)
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||||
include: { contract: { include: { employee: true } } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
// 验证码校验
|
||||
const stored = codeStore.get(`contract-${data.token}`)
|
||||
if (!stored || stored.expiresAt < Date.now()) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||||
}
|
||||
if (stored.failCount >= 5) {
|
||||
codeStore.delete(`contract-${data.token}`)
|
||||
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
|
||||
}
|
||||
if (stored.code !== data.verifyCode) {
|
||||
stored.failCount++
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
|
||||
}
|
||||
codeStore.delete(`contract-${data.token}`)
|
||||
|
||||
const userAgent = req.headers['user-agent'] || ''
|
||||
const signEvidence = JSON.stringify({
|
||||
ip: req.ip,
|
||||
userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
await prisma.contractConfirmLink.update({
|
||||
where: { id: link.id },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date(), confirmedIp: req.ip },
|
||||
})
|
||||
await prisma.laborContract.update({
|
||||
where: { id: link.contractId },
|
||||
data: { attachmentName: `confirmed:${new Date().toISOString()}|evidence:${signEvidence}` },
|
||||
})
|
||||
res.json({ success: true, data: { message: '合同签署确认成功' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取入职填报信息(通过 token)
|
||||
router.get('/onboarding/:token', async (req, res, next) => {
|
||||
try {
|
||||
const link = await prisma.onboardingLink.findFirst({
|
||||
where: { token: req.params.token, status: 'PENDING', expiresAt: { gt: new Date() } },
|
||||
include: { org: { select: { name: true } } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
res.json({ success: true, data: { orgName: link.org.name } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 撤回入职链接(HR 端调用,需要认证)
|
||||
router.post('/onboarding/:id/revoke', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const link = await prisma.onboardingLink.findFirst({
|
||||
where: { id: req.params.id, orgId: req.employee.orgId },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '链接不存在' } })
|
||||
}
|
||||
if (link.status !== 'PENDING') {
|
||||
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '仅待填报状态的链接可撤回' } })
|
||||
}
|
||||
await prisma.onboardingLink.update({
|
||||
where: { id: link.id },
|
||||
data: { status: 'CANCELLED' },
|
||||
})
|
||||
res.json({ success: true, data: { message: '入职链接已撤回' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取合同确认信息(通过 token)
|
||||
router.get('/contract-confirm/:token', async (req, res, next) => {
|
||||
try {
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { token: req.params.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||||
include: {
|
||||
contract: {
|
||||
include: {
|
||||
employee: { select: { name: true, org: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
orgName: link.contract.employee.org.name,
|
||||
employeeName: link.contract.employee.name,
|
||||
contract: link.contract,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 重发合同确认链接(HR 端调用,需要认证)
|
||||
router.post('/contract-confirm/:id/resend', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { id: req.params.id, orgId: req.employee.orgId },
|
||||
include: { contract: { include: { employee: true } } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '链接不存在' } })
|
||||
}
|
||||
if (link.status === 'CONFIRMED') {
|
||||
return res.status(400).json({ success: false, error: { code: 'ALREADY_CONFIRMED', message: '合同已确认,无需重发' } })
|
||||
}
|
||||
// 生成新 token 并延长过期时间
|
||||
const crypto = await import('crypto')
|
||||
const newToken = crypto.randomUUID()
|
||||
await prisma.contractConfirmLink.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
token: newToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'UNCONFIRMED',
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: { token: newToken, message: '确认链接已重发,有效期7天' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 入职文件上传
|
||||
const uploadDir = path.join(process.cwd(), 'uploads', 'onboarding')
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true })
|
||||
|
||||
const onboardingUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: uploadDir,
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname)
|
||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`)
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp']
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
if (allowed.includes(ext)) cb(null, true)
|
||||
else cb(new Error('仅支持 JPG/PNG/PDF/BMP 格式'))
|
||||
},
|
||||
})
|
||||
|
||||
router.post('/onboarding/:token/upload', onboardingUpload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } })
|
||||
}
|
||||
const link = await prisma.onboardingLink.findFirst({
|
||||
where: { token: req.params.token, status: 'PENDING', expiresAt: { gt: new Date() } },
|
||||
})
|
||||
if (!link) {
|
||||
fs.unlinkSync(req.file.path)
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
const fileType = (req.body.fileType as string) || 'OTHER'
|
||||
const fileUrl = `/uploads/onboarding/${req.file.filename}`
|
||||
res.json({ success: true, data: { fileName: req.file.originalname, fileUrl, fileType, fileSize: req.file.size } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,791 @@
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt, encrypt } from '../lib/crypto'
|
||||
import { getContractStatus } from '../services/contract.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function safeDecrypt(encrypted: string): number {
|
||||
try {
|
||||
if (!encrypted || !encrypted.includes(':')) return Number(encrypted) || 0
|
||||
return Number(decrypt(encrypted))
|
||||
} catch {
|
||||
return Number(encrypted) || 0
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 花名册聚合 API ==========
|
||||
|
||||
// 花名册列表(含汇总信息,支持分页和过滤)
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100)
|
||||
const search = req.query.search as string
|
||||
const status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED
|
||||
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
// 先查询满足 orgId 和搜索条件的员工
|
||||
const whereBase: any = { orgId: req.user!.orgId }
|
||||
if (search) {
|
||||
whereBase.OR = [
|
||||
{ name: { contains: search } },
|
||||
{ department: { contains: search } },
|
||||
]
|
||||
}
|
||||
|
||||
const [total, employees] = await Promise.all([
|
||||
prisma.employee.count({ where: whereBase }),
|
||||
prisma.employee.findMany({
|
||||
where: whereBase,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
|
||||
_count: {
|
||||
select: {
|
||||
disciplinaryRecords: true,
|
||||
attendanceRecords: true,
|
||||
trainingRecords: true,
|
||||
performanceRecords: true,
|
||||
payslips: true,
|
||||
overtimeRecords: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
// 计算动态状态和合同状态
|
||||
let result = employees.map((e) => {
|
||||
const latestContract = e.contracts[0] || null
|
||||
const contractInfo = latestContract
|
||||
? getContractStatus({
|
||||
signDate: latestContract.signDate,
|
||||
startDate: latestContract.startDate,
|
||||
endDate: latestContract.endDate,
|
||||
contractType: latestContract.contractType,
|
||||
hireDate: e.hireDate,
|
||||
})
|
||||
: getContractStatus({
|
||||
signDate: null,
|
||||
startDate: e.hireDate,
|
||||
endDate: null,
|
||||
contractType: 'UNSIGNED',
|
||||
hireDate: e.hireDate,
|
||||
})
|
||||
const isResigned = e.terminations.some((t) => t.terminationDate <= today)
|
||||
const isPreHire = !isResigned && e.hireDate > today
|
||||
const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE')
|
||||
return {
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
department: e.department,
|
||||
city: e.city,
|
||||
status: dynamicStatus,
|
||||
hasTermination: e.terminations.length > 0,
|
||||
latestTerminationDate: e.terminations[0]?.terminationDate || null,
|
||||
latestTerminationType: e.terminations[0]?.type || null,
|
||||
latestTerminationId: e.terminations[0]?.id || null,
|
||||
hireDate: e.hireDate,
|
||||
gender: e.gender,
|
||||
phone: e.phone,
|
||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||
latestContract,
|
||||
contractStatus: contractInfo.status,
|
||||
contractStatusText: contractInfo.statusText,
|
||||
riskLevel: contractInfo.riskLevel,
|
||||
counts: e._count,
|
||||
}
|
||||
})
|
||||
|
||||
// 前端过滤:状态和合同状态(因为合同状态需要后处理,不适合放 Prisma where)
|
||||
if (status) {
|
||||
result = result.filter((e) => e.status === status)
|
||||
}
|
||||
if (contractStatus) {
|
||||
result = result.filter((e) => e.contractStatus === contractStatus)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 员工完整档案(花名册详情)
|
||||
router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' } },
|
||||
payslips: { orderBy: { month: 'desc' } },
|
||||
overtimeRecords: { orderBy: { month: 'desc' } },
|
||||
disciplinaryRecords: { orderBy: { violationDate: 'desc' } },
|
||||
attendanceRecords: { orderBy: { date: 'desc' }, take: 90 },
|
||||
trainingRecords: { orderBy: { trainingDate: 'desc' } },
|
||||
performanceRecords: { orderBy: { period: 'desc' } },
|
||||
terminations: { orderBy: { createdAt: 'desc' } },
|
||||
attachments: true,
|
||||
},
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const dynamicStatus = employee.terminations.some((t) => t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE'
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...rest,
|
||||
status: dynamicStatus,
|
||||
monthlySalary: safeDecrypt(monthlySalary),
|
||||
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
|
||||
idCardNumber: idCardNumber ? safeDecrypt(idCardNumber).toString() : null,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 仲裁证据链导出
|
||||
router.get('/:id/evidence-chain', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' } },
|
||||
payslips: { orderBy: { month: 'desc' } },
|
||||
overtimeRecords: { orderBy: { month: 'desc' } },
|
||||
disciplinaryRecords: { orderBy: { violationDate: 'desc' } },
|
||||
attendanceRecords: { orderBy: { date: 'desc' } },
|
||||
trainingRecords: { orderBy: { trainingDate: 'desc' } },
|
||||
performanceRecords: { orderBy: { period: 'desc' } },
|
||||
terminations: true,
|
||||
},
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
|
||||
const evidence: any[] = []
|
||||
const empName = employee.name
|
||||
const empDept = employee.department
|
||||
const hireDate = employee.hireDate.toISOString().slice(0, 10)
|
||||
|
||||
// 1. 劳动关系证据
|
||||
evidence.push({
|
||||
category: '劳动关系',
|
||||
title: '入职登记',
|
||||
date: hireDate,
|
||||
description: `${empName}于${hireDate}入职${empDept},建立劳动关系。`,
|
||||
evidenceType: 'EMPLOYMENT',
|
||||
})
|
||||
employee.contracts.forEach((c) => {
|
||||
evidence.push({
|
||||
category: '劳动关系',
|
||||
title: `劳动合同(${c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签订'})`,
|
||||
date: c.signDate ? c.signDate.toISOString().slice(0, 10) : c.startDate.toISOString().slice(0, 10),
|
||||
description: `合同期限:${c.startDate.toISOString().slice(0, 10)} 至 ${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},试用期${c.probationMonths}个月,试用期工资¥${c.probationSalary}。`,
|
||||
evidenceType: 'CONTRACT',
|
||||
signed: !!c.signDate,
|
||||
})
|
||||
})
|
||||
|
||||
// 2. 薪酬证据
|
||||
employee.payslips.forEach((p) => {
|
||||
evidence.push({
|
||||
category: '薪酬发放',
|
||||
title: `${p.month}月工资条`,
|
||||
date: p.month,
|
||||
description: `基本工资¥${p.baseSalary.toFixed(2)},加班费¥${p.overtimePay.toFixed(2)},津贴¥${p.allowance.toFixed(2)},扣款¥${p.deduction.toFixed(2)},应发合计¥${p.totalPay.toFixed(2)}。${p.confirmedAt ? '员工已确认。' : '员工未确认。'}`,
|
||||
evidenceType: 'PAYSLIP',
|
||||
confirmed: !!p.confirmedAt,
|
||||
})
|
||||
})
|
||||
employee.overtimeRecords.forEach((o) => {
|
||||
if (o.totalPay > 0) {
|
||||
evidence.push({
|
||||
category: '薪酬发放',
|
||||
title: `${o.month}月加班费记录`,
|
||||
date: o.month,
|
||||
description: `工作日加班${o.weekdayHours}h,休息日加班${o.weekendHours}h,节假日加班${o.holidayHours}h,加班费合计¥${o.totalPay.toFixed(2)}。`,
|
||||
evidenceType: 'OVERTIME',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 3. 考勤证据
|
||||
const abnormalAttendance = employee.attendanceRecords.filter((a) => a.status !== 'NORMAL')
|
||||
abnormalAttendance.forEach((a) => {
|
||||
const statusMap: Record<string, string> = { LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' }
|
||||
evidence.push({
|
||||
category: '考勤记录',
|
||||
title: `${a.date.toISOString().slice(0, 10)} 考勤异常`,
|
||||
date: a.date.toISOString().slice(0, 10),
|
||||
description: `状态:${statusMap[a.status] || a.status}${a.lateMinutes ? `,迟到${a.lateMinutes}分钟` : ''}${a.earlyMinutes ? `,早退${a.earlyMinutes}分钟` : ''}。${a.remark || ''}`,
|
||||
evidenceType: 'ATTENDANCE',
|
||||
})
|
||||
})
|
||||
|
||||
// 4. 违纪证据
|
||||
employee.disciplinaryRecords.forEach((d) => {
|
||||
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
|
||||
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
|
||||
evidence.push({
|
||||
category: '违纪处理',
|
||||
title: `${d.violationDate.toISOString().slice(0, 10)} ${typeMap[d.violationType] || d.violationType}`,
|
||||
date: d.violationDate.toISOString().slice(0, 10),
|
||||
description: `违纪事实:${d.description}。处理结果:${actionMap[d.action] || d.action}。${d.employeeAck ? `员工已签字确认(${d.ackDate ? d.ackDate.toISOString().slice(0, 10) : ''})。` : '员工未签字。'}${d.witness ? `见证人:${d.witness}。` : ''}`,
|
||||
evidenceType: 'DISCIPLINARY',
|
||||
acknowledged: d.employeeAck,
|
||||
})
|
||||
})
|
||||
|
||||
// 5. 培训签收证据
|
||||
employee.trainingRecords.forEach((t) => {
|
||||
const ackMap: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
|
||||
evidence.push({
|
||||
category: '培训签收',
|
||||
title: `${t.trainingDate.toISOString().slice(0, 10)} ${t.topic}`,
|
||||
date: t.trainingDate.toISOString().slice(0, 10),
|
||||
description: `培训主题:${t.topic}。时长:${t.duration}小时。${t.content ? `内容:${t.content}。` : ''}签收状态:${ackMap[t.ackStatus] || t.ackStatus}。`,
|
||||
evidenceType: 'TRAINING',
|
||||
acknowledged: t.ackStatus === 'SIGNED',
|
||||
})
|
||||
})
|
||||
|
||||
// 6. 绩效证据
|
||||
employee.performanceRecords.forEach((p) => {
|
||||
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
|
||||
evidence.push({
|
||||
category: '绩效考核',
|
||||
title: `${p.period} 绩效考核`,
|
||||
date: p.period,
|
||||
description: `得分:${p.score},等级:${p.grade},结果:${resultMap[p.result] || p.result}。${p.summary ? `评语:${p.summary}。` : ''}${p.improvementPlan ? `改进计划:${p.improvementPlan}。` : ''}${p.employeeAck ? '员工已签字确认。' : '员工未签字。'}`,
|
||||
evidenceType: 'PERFORMANCE',
|
||||
acknowledged: p.employeeAck,
|
||||
})
|
||||
})
|
||||
|
||||
// 7. 解聘证据
|
||||
employee.terminations.forEach((t) => {
|
||||
const reasonMap: Record<string, string> = { NEGOTIATED: '协商解除', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期' }
|
||||
evidence.push({
|
||||
category: '解聘记录',
|
||||
title: `${t.terminationDate.toISOString().slice(0, 10)} 解聘记录`,
|
||||
date: t.terminationDate.toISOString().slice(0, 10),
|
||||
description: `解聘原因:${reasonMap[t.reason] || t.reason}。经济补偿金:¥${t.compensation.toFixed(2)}。${t.remark || ''}`,
|
||||
evidenceType: 'TERMINATION',
|
||||
})
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
employee: {
|
||||
name: empName,
|
||||
department: empDept,
|
||||
hireDate,
|
||||
status: employee.terminations.some((t) => t.terminationDate <= new Date()) ? 'RESIGNED' : 'ACTIVE',
|
||||
gender: employee.gender,
|
||||
phone: employee.phone,
|
||||
},
|
||||
evidence,
|
||||
summary: {
|
||||
total: evidence.length,
|
||||
signed: evidence.filter((e) => e.acknowledged === true).length,
|
||||
unsigned: evidence.filter((e) => e.acknowledged === false).length,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 违纪记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.disciplinaryRecord.findMany({
|
||||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||||
orderBy: { violationDate: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||||
const record = await prisma.disciplinaryRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.employeeId,
|
||||
violationDate: new Date(violationDate),
|
||||
violationType,
|
||||
description,
|
||||
severity: severity || 'WARNING',
|
||||
action: action || 'ORAL_WARNING',
|
||||
actionDetail,
|
||||
employeeAck: employeeAck || false,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
ackMethod,
|
||||
witness,
|
||||
attachmentUrl,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
await auditLog(req, 'CREATE', 'DISCIPLINARY', record.id, { employeeId: req.params.employeeId })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||||
const record = await prisma.disciplinaryRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
const updated = await prisma.disciplinaryRecord.update({
|
||||
where: { id: req.params.recordId },
|
||||
data: {
|
||||
violationDate: violationDate ? new Date(violationDate) : undefined,
|
||||
violationType,
|
||||
description,
|
||||
severity,
|
||||
action,
|
||||
actionDetail,
|
||||
employeeAck,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
ackMethod,
|
||||
witness,
|
||||
attachmentUrl,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.disciplinaryRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
await prisma.disciplinaryRecord.delete({ where: { id: req.params.recordId } })
|
||||
res.json({ success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 考勤记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.attendanceRecord.findMany({
|
||||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 90,
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { date, checkInTime, checkOutTime, status, lateMinutes, earlyMinutes, workHours, overtimeHours, remark } = req.body
|
||||
const record = await prisma.attendanceRecord.upsert({
|
||||
where: { employeeId_date: { employeeId: req.params.employeeId, date: new Date(date) } },
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.employeeId,
|
||||
date: new Date(date),
|
||||
checkInTime,
|
||||
checkOutTime,
|
||||
status: status || 'NORMAL',
|
||||
lateMinutes: lateMinutes || 0,
|
||||
earlyMinutes: earlyMinutes || 0,
|
||||
workHours: workHours || 0,
|
||||
overtimeHours: overtimeHours || 0,
|
||||
remark,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
update: {
|
||||
checkInTime,
|
||||
checkOutTime,
|
||||
status,
|
||||
lateMinutes,
|
||||
earlyMinutes,
|
||||
workHours,
|
||||
overtimeHours,
|
||||
remark,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:employeeId/attendance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.attendanceRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
await prisma.attendanceRecord.delete({ where: { id: req.params.recordId } })
|
||||
res.json({ success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 培训签收记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/training', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.trainingRecord.findMany({
|
||||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||||
orderBy: { trainingDate: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:employeeId/training', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body
|
||||
const record = await prisma.trainingRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.employeeId,
|
||||
trainingDate: new Date(trainingDate),
|
||||
topic,
|
||||
content,
|
||||
trainer,
|
||||
duration: duration || 0,
|
||||
ackStatus: ackStatus || 'PENDING',
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
attachmentUrl,
|
||||
remark,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
await auditLog(req, 'CREATE', 'TRAINING', record.id, { employeeId: req.params.employeeId })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.put('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body
|
||||
const record = await prisma.trainingRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
const updated = await prisma.trainingRecord.update({
|
||||
where: { id: req.params.recordId },
|
||||
data: {
|
||||
trainingDate: trainingDate ? new Date(trainingDate) : undefined,
|
||||
topic,
|
||||
content,
|
||||
trainer,
|
||||
duration,
|
||||
ackStatus,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
attachmentUrl,
|
||||
remark,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.trainingRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
await prisma.trainingRecord.delete({ where: { id: req.params.recordId } })
|
||||
res.json({ success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 绩效记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.performanceRecord.findMany({
|
||||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||||
orderBy: { period: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
|
||||
const record = await prisma.performanceRecord.upsert({
|
||||
where: { employeeId_period: { employeeId: req.params.employeeId, period } },
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.employeeId,
|
||||
period,
|
||||
score: score || 0,
|
||||
grade: grade || 'B',
|
||||
result: result || 'QUALIFIED',
|
||||
summary,
|
||||
improvementPlan,
|
||||
employeeAck: employeeAck || false,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
reviewer,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
update: {
|
||||
score,
|
||||
grade,
|
||||
result,
|
||||
summary,
|
||||
improvementPlan,
|
||||
employeeAck,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
reviewer,
|
||||
},
|
||||
})
|
||||
await auditLog(req, 'CREATE', 'PERFORMANCE', record.id, { employeeId: req.params.employeeId })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
|
||||
const record = await prisma.performanceRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
const updated = await prisma.performanceRecord.update({
|
||||
where: { id: req.params.recordId },
|
||||
data: {
|
||||
period,
|
||||
score,
|
||||
grade,
|
||||
result,
|
||||
summary,
|
||||
improvementPlan,
|
||||
employeeAck,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
reviewer,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.performanceRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
await prisma.performanceRecord.delete({ where: { id: req.params.recordId } })
|
||||
res.json({ success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 调薪/调部门 API ==========
|
||||
|
||||
function dateToMonth(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${y}-${m}`
|
||||
}
|
||||
|
||||
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')}`
|
||||
}
|
||||
|
||||
// 调薪
|
||||
router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { newSalary, effectiveMonth, reason } = req.body
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
|
||||
const oldSalary = safeDecrypt(employee.monthlySalary)
|
||||
const effMonth = effectiveMonth || dateToMonth(new Date())
|
||||
const prevEffMonth = prevMonth(effMonth)
|
||||
|
||||
// 关闭之前有效记录
|
||||
await prisma.salaryChangeRecord.updateMany({
|
||||
where: { employeeId: req.params.id, endMonth: null },
|
||||
data: { endMonth: prevEffMonth },
|
||||
})
|
||||
|
||||
// 创建新薪资记录
|
||||
const record = await prisma.salaryChangeRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.id,
|
||||
oldSalary,
|
||||
newSalary: Number(newSalary),
|
||||
effectiveDate: new Date(`${effMonth}-01`),
|
||||
effectiveMonth: effMonth,
|
||||
endMonth: null,
|
||||
changeType: 'SALARY_CHANGE',
|
||||
reason: reason || null,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步 Employee 便捷字段
|
||||
await prisma.employee.update({
|
||||
where: { id: req.params.id },
|
||||
data: { monthlySalary: encrypt(String(newSalary)) },
|
||||
})
|
||||
|
||||
await auditLog(req, 'CREATE', 'SALARY_CHANGE', record.id, { employeeId: req.params.id, oldSalary, newSalary })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 调薪历史
|
||||
router.get('/:id/salary-records', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.salaryChangeRecord.findMany({
|
||||
where: { employeeId: req.params.id, orgId: req.user!.orgId },
|
||||
orderBy: { effectiveDate: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 调部门
|
||||
router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { newDepartment, effectiveMonth, reason } = req.body
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
|
||||
const oldDepartment = employee.department
|
||||
const effMonth = effectiveMonth || dateToMonth(new Date())
|
||||
const prevEffMonth = prevMonth(effMonth)
|
||||
|
||||
// 关闭之前有效记录
|
||||
await prisma.employeeDepartmentRecord.updateMany({
|
||||
where: { employeeId: req.params.id, endMonth: null },
|
||||
data: { endMonth: prevEffMonth },
|
||||
})
|
||||
|
||||
// 创建新部门记录
|
||||
const record = await prisma.employeeDepartmentRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.id,
|
||||
oldDepartment,
|
||||
newDepartment,
|
||||
effectiveMonth: effMonth,
|
||||
endMonth: null,
|
||||
changeType: 'TRANSFER',
|
||||
reason: reason || null,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步 Employee 便捷字段
|
||||
await prisma.employee.update({
|
||||
where: { id: req.params.id },
|
||||
data: { department: newDepartment },
|
||||
})
|
||||
|
||||
await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 调部门历史
|
||||
router.get('/:id/department-records', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.employeeDepartmentRecord.findMany({
|
||||
where: { employeeId: req.params.id, orgId: req.user!.orgId },
|
||||
orderBy: { effectiveMonth: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 30天内合同到期列表
|
||||
router.get('/contracts/expiring', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const days = parseInt(req.query.days as string) || 30
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const future = new Date(today)
|
||||
future.setDate(future.getDate() + days)
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
|
||||
include: {
|
||||
contracts: {
|
||||
where: {
|
||||
endDate: { gte: today, lte: future },
|
||||
contractType: 'FIXED',
|
||||
},
|
||||
orderBy: { endDate: 'asc' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = employees
|
||||
.filter(e => e.contracts.length > 0)
|
||||
.map(e => {
|
||||
const contract = e.contracts[0]
|
||||
const endDate = new Date(contract.endDate!)
|
||||
const daysLeft = Math.ceil((endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||
return {
|
||||
employeeId: e.id,
|
||||
employeeName: e.name,
|
||||
department: e.department,
|
||||
contractEndDate: contract.endDate,
|
||||
daysLeft,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.daysLeft - b.daysLeft)
|
||||
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Router } from 'express'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||
email: z.string().email().optional(),
|
||||
role: z.enum(['ADMIN', 'HR', 'VIEWER']).optional(),
|
||||
})
|
||||
|
||||
const createUserSchema = z.object({
|
||||
name: z.string().min(1, '姓名不能为空'),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
password: z.string().min(6, '密码至少6位'),
|
||||
role: z.enum(['ADMIN', 'HR', 'VIEWER']).default('HR'),
|
||||
})
|
||||
|
||||
// 获取企业信息
|
||||
router.get('/org', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id: req.user!.orgId },
|
||||
select: { id: true, name: true, plan: true, maxEmployees: true, createdAt: true },
|
||||
})
|
||||
res.json({ success: true, data: org })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新企业信息
|
||||
router.put('/org', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { name, payrollFrequency } = req.body as { name?: string; payrollFrequency?: number }
|
||||
const updateData: any = {}
|
||||
if (name) updateData.name = name
|
||||
if (payrollFrequency !== undefined) updateData.payrollFrequency = payrollFrequency
|
||||
const org = await prisma.organization.update({
|
||||
where: { id: req.user!.orgId },
|
||||
data: updateData,
|
||||
select: { id: true, name: true, plan: true, maxEmployees: true, payrollFrequency: true },
|
||||
})
|
||||
res.json({ success: true, data: org })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取用户列表
|
||||
router.get('/users', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
select: { id: true, name: true, phone: true, email: true, role: true, disabled: true, createdAt: true, lastLoginAt: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
res.json({ success: true, data: users })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 添加用户
|
||||
router.post('/users', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createUserSchema.parse(req.body)
|
||||
const existing = await prisma.user.findFirst({ where: { phone: data.phone, orgId: req.user!.orgId } })
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, error: { code: 'DUPLICATE', message: '该手机号已存在' } })
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(data.password, 10)
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
passwordHash,
|
||||
role: data.role,
|
||||
},
|
||||
select: { id: true, name: true, phone: true, role: true },
|
||||
})
|
||||
res.json({ success: true, data: user })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新用户
|
||||
router.put('/users/:id', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = updateUserSchema.parse(req.body)
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: data,
|
||||
select: { id: true, name: true, phone: true, email: true, role: true, disabled: true },
|
||||
})
|
||||
res.json({ success: true, data: user })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除用户
|
||||
router.delete('/users/:id', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
if (req.params.id === req.user!.id) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能删除自己' } })
|
||||
}
|
||||
await prisma.user.delete({ where: { id: req.params.id } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 禁用/启用用户
|
||||
router.patch('/users/:id/toggle-disable', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
if (req.params.id === req.user!.id) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能禁用自己' } })
|
||||
}
|
||||
const existing = await prisma.user.findUnique({ where: { id: req.params.id } })
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '用户不存在' } })
|
||||
}
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: { disabled: !existing.disabled },
|
||||
select: { id: true, name: true, disabled: true },
|
||||
})
|
||||
res.json({ success: true, data: user })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 切换套餐
|
||||
router.put('/plan', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { plan } = req.body as { plan: 'FREE' | 'PRO' | 'ENTERPRISE' }
|
||||
if (!['FREE', 'PRO', 'ENTERPRISE'].includes(plan)) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '无效的套餐' } })
|
||||
}
|
||||
const maxEmployees = plan === 'FREE' ? 10 : plan === 'PRO' ? 100 : 999999
|
||||
const org = await prisma.organization.update({
|
||||
where: { id: req.user!.orgId },
|
||||
data: { plan, maxEmployees },
|
||||
select: { id: true, name: true, plan: true, maxEmployees: true },
|
||||
})
|
||||
res.json({ success: true, data: org })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 用量统计
|
||||
router.get('/usage', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const [employeeCount, aiConversations, contracts] = await Promise.all([
|
||||
prisma.employee.count({ where: { orgId } }),
|
||||
prisma.aIConversation.count({ where: { orgId } }),
|
||||
prisma.laborContract.count({ where: { orgId } }),
|
||||
])
|
||||
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { plan: true, maxEmployees: true } })
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
plan: org?.plan || 'FREE',
|
||||
maxEmployees: org?.maxEmployees || 10,
|
||||
employeeCount,
|
||||
aiConversations,
|
||||
contracts,
|
||||
employeeUsage: `${employeeCount}/${org?.maxEmployees || 10}`,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,956 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
const socialConfigFields = {
|
||||
city: z.string().optional(),
|
||||
pensionOrg: z.number().optional(),
|
||||
pensionEmp: z.number().optional(),
|
||||
medicalOrg: z.number().optional(),
|
||||
medicalEmp: z.number().optional(),
|
||||
unemploymentOrg: z.number().optional(),
|
||||
unemploymentEmp: z.number().optional(),
|
||||
injuryOrg: z.number().optional(),
|
||||
maternityOrg: z.number().optional(),
|
||||
baseMin: z.number().optional(),
|
||||
baseMax: z.number().optional(),
|
||||
}
|
||||
|
||||
const housingConfigFields = {
|
||||
city: z.string().optional(),
|
||||
housingOrg: z.number().optional(),
|
||||
housingEmp: z.number().optional(),
|
||||
baseMin: z.number().optional(),
|
||||
baseMax: z.number().optional(),
|
||||
}
|
||||
|
||||
// 获取当前生效版本(支持按城市筛选)
|
||||
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const city = req.query.city as string | undefined
|
||||
const where: any = { orgId: req.user!.orgId, isCurrent: true }
|
||||
if (city) where.city = city
|
||||
let config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where,
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
// 未指定城市时,返回任意当前配置
|
||||
if (!config && !city) {
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
try {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
city: city || '北京',
|
||||
isCurrent: true,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// 唯一约束冲突,查询同城市任意配置
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, city: city || '北京' },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!config) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到社保配置' } })
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取所有城市列表(从配置中提取)
|
||||
router.get('/config/cities', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const configs = await prisma.socialInsuranceConfig.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
select: { city: true },
|
||||
distinct: ['city'],
|
||||
})
|
||||
const cities = configs.map(c => c.city).filter(Boolean)
|
||||
if (!cities.includes('北京')) cities.unshift('北京')
|
||||
res.json({ success: true, data: cities })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取所有版本列表(支持按城市筛选)
|
||||
router.get('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const city = req.query.city as string | undefined
|
||||
const where: any = { orgId: req.user!.orgId }
|
||||
if (city) where.city = city
|
||||
const versions = await prisma.socialInsuranceConfig.findMany({
|
||||
where,
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: versions })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 按月份获取适用版本
|
||||
router.get('/config/by-month/:month', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month } = req.params
|
||||
const config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: {
|
||||
orgId: req.user!.orgId,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (!config) {
|
||||
// 回退到当前版本
|
||||
const current = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, isCurrent: true },
|
||||
})
|
||||
return res.json({ success: true, data: current })
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 新建版本(年度调基/比例变更)
|
||||
const createVersionSchema = z.object({
|
||||
...socialConfigFields,
|
||||
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
})
|
||||
|
||||
router.post('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = createVersionSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 检查同一城市同一生效月份是否已有版本
|
||||
const existing = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, city: data.city, effectiveFrom: data.effectiveFrom },
|
||||
})
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有配置版本` })
|
||||
}
|
||||
|
||||
// 将之前当前版本标记为失效
|
||||
const prevCurrent = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, isCurrent: true },
|
||||
})
|
||||
if (prevCurrent) {
|
||||
// 计算上个版本的失效月份 = 新版本生效月份的前一个月
|
||||
const [year, mon] = data.effectiveFrom.split('-').map(Number)
|
||||
const prevMonth = mon === 1
|
||||
? `${year - 1}-12`
|
||||
: `${year}-${String(mon - 1).padStart(2, '0')}`
|
||||
await prisma.socialInsuranceConfig.update({
|
||||
where: { id: prevCurrent.id },
|
||||
data: { isCurrent: false, effectiveTo: prevMonth },
|
||||
})
|
||||
}
|
||||
|
||||
// 创建新版本
|
||||
const version = await prisma.socialInsuranceConfig.create({
|
||||
data: {
|
||||
orgId,
|
||||
...data,
|
||||
isCurrent: true,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: version })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 预览员工基数调整(返回全部在职员工,含当前基数和建议基数)
|
||||
router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE', city: config.city },
|
||||
select: { id: true, name: true, department: true, socialInsBase: true, monthlySalary: true },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
// 计算上年平均工资:查询过去12个月的Payslip的totalPay平均值
|
||||
const now = new Date()
|
||||
const lastYearStart = `${now.getFullYear() - 1}-01`
|
||||
const lastYearEnd = `${now.getFullYear() - 1}-12`
|
||||
|
||||
const lastYearPayslips = await prisma.payslip.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
month: { gte: lastYearStart, lte: lastYearEnd },
|
||||
},
|
||||
select: { employeeId: true, totalPay: true },
|
||||
})
|
||||
|
||||
// 按员工汇总上年月均工资
|
||||
const avgSalaryMap = new Map<string, number>()
|
||||
const empPayslipMap = new Map<string, number[]>()
|
||||
for (const p of lastYearPayslips) {
|
||||
if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, [])
|
||||
empPayslipMap.get(p.employeeId)!.push(p.totalPay)
|
||||
}
|
||||
for (const [empId, pays] of empPayslipMap) {
|
||||
const avg = pays.reduce((s, v) => s + v, 0) / pays.length
|
||||
avgSalaryMap.set(empId, avg)
|
||||
}
|
||||
|
||||
const items = employees.map((emp) => {
|
||||
let monthlyWage = 0
|
||||
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
|
||||
const oldSocialBase = emp.socialInsBase ?? monthlyWage
|
||||
const avgSalary = avgSalaryMap.get(emp.id) ?? monthlyWage
|
||||
const suggestedSocialBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
|
||||
return {
|
||||
employeeId: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
oldBase: oldSocialBase,
|
||||
avgSalary,
|
||||
monthlyWage,
|
||||
suggestedBase: suggestedSocialBase,
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 执行员工基数调整(接收用户编辑后的数据)
|
||||
const adjustApplySchema = z.object({
|
||||
items: z.array(z.object({
|
||||
employeeId: z.string(),
|
||||
newBase: z.number(),
|
||||
})),
|
||||
})
|
||||
|
||||
router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
|
||||
|
||||
const { items } = adjustApplySchema.parse(req.body)
|
||||
const adjustMonth = config.effectiveFrom
|
||||
const prevAdjustMonth = (() => {
|
||||
const [y, m] = adjustMonth.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
})()
|
||||
|
||||
let adjusted = 0
|
||||
for (const item of items) {
|
||||
const socialBase = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax)
|
||||
|
||||
// 关闭旧社保记录
|
||||
await prisma.employeeSocialInsRecord.updateMany({
|
||||
where: { employeeId: item.employeeId, endMonth: null },
|
||||
data: { endMonth: prevAdjustMonth },
|
||||
})
|
||||
|
||||
// 创建新社保记录
|
||||
await prisma.employeeSocialInsRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: item.employeeId,
|
||||
city: config.city,
|
||||
startMonth: adjustMonth,
|
||||
endMonth: null,
|
||||
base: socialBase,
|
||||
changeType: 'ADJUST',
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步 Employee 便捷字段
|
||||
await prisma.employee.update({
|
||||
where: { id: item.employeeId },
|
||||
data: { socialInsBase: socialBase, socialInsStartMonth: adjustMonth },
|
||||
})
|
||||
adjusted++
|
||||
}
|
||||
|
||||
await prisma.socialInsuranceConfig.update({
|
||||
where: { id },
|
||||
data: { adjustmentDone: true },
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { adjusted, total: items.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 重置社保基数调整(撤销本次调整,重新来过)
|
||||
router.post('/config/:id/reset-adjustment', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
|
||||
if (!config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本尚未执行过基数调整,无需重置' })
|
||||
|
||||
// 恢复 adjustmentDone 标志
|
||||
await prisma.socialInsuranceConfig.update({
|
||||
where: { id },
|
||||
data: { adjustmentDone: false },
|
||||
})
|
||||
|
||||
// 删除该版本创建的所有社保记录变更(按城市筛选)
|
||||
await prisma.employeeSocialInsRecord.deleteMany({
|
||||
where: {
|
||||
orgId,
|
||||
city: config.city,
|
||||
changeType: 'ADJUST',
|
||||
startMonth: config.effectiveFrom,
|
||||
},
|
||||
})
|
||||
|
||||
// 恢复员工社保基数为调整前(找到 adjustment 前的最后一条记录,按城市)
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE', city: config.city },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
for (const emp of employees) {
|
||||
const prevRecord = await prisma.employeeSocialInsRecord.findFirst({
|
||||
where: { orgId, employeeId: emp.id, city: config.city, startMonth: { lt: config.effectiveFrom } },
|
||||
orderBy: { startMonth: 'desc' },
|
||||
})
|
||||
await prisma.employee.update({
|
||||
where: { id: emp.id },
|
||||
data: {
|
||||
socialInsBase: prevRecord?.base ?? null,
|
||||
socialInsStartMonth: prevRecord?.startMonth ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ success: true, message: '社保基数调整已重置,可以重新调整' })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 社保计算(使用当前版本或指定月份版本)
|
||||
const calcSchema = z.object({
|
||||
base: z.number().positive(),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||
city: z.string().optional(),
|
||||
})
|
||||
|
||||
router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { base, month, city } = calcSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
let config
|
||||
const whereBase: any = { orgId }
|
||||
if (city) whereBase.city = city
|
||||
if (month) {
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: {
|
||||
...whereBase,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { ...whereBase, isCurrent: true },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), city: city || '北京', createdBy: req.user!.id },
|
||||
})
|
||||
}
|
||||
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
|
||||
const pensionOrg = actualBase * config.pensionOrg / 100
|
||||
const pensionEmp = actualBase * config.pensionEmp / 100
|
||||
const medicalOrg = actualBase * config.medicalOrg / 100
|
||||
const medicalEmp = actualBase * config.medicalEmp / 100
|
||||
const unemploymentOrg = actualBase * config.unemploymentOrg / 100
|
||||
const unemploymentEmp = actualBase * config.unemploymentEmp / 100
|
||||
const injuryOrg = actualBase * config.injuryOrg / 100
|
||||
const maternityOrg = actualBase * config.maternityOrg / 100
|
||||
const totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg
|
||||
const totalEmp = pensionEmp + medicalEmp + unemploymentEmp
|
||||
const total = totalOrg + totalEmp
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
actualBase,
|
||||
originalBase: base,
|
||||
capped: base > config.baseMax,
|
||||
floored: base < config.baseMin,
|
||||
configVersion: config.effectiveFrom,
|
||||
items: [
|
||||
{ name: '养老保险', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: pensionOrg, empAmount: pensionEmp },
|
||||
{ name: '医疗保险', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalOrg, empAmount: medicalEmp },
|
||||
{ name: '失业保险', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: unemploymentOrg, empAmount: unemploymentEmp },
|
||||
{ name: '工伤保险', orgRate: config.injuryOrg, empRate: 0, orgAmount: injuryOrg, empAmount: 0 },
|
||||
{ name: '生育保险', orgRate: config.maternityOrg, empRate: 0, orgAmount: maternityOrg, empAmount: 0 },
|
||||
],
|
||||
totalOrg,
|
||||
totalEmp,
|
||||
total,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 公积金配置 ==========
|
||||
|
||||
// 获取当前公积金配置(支持按城市筛选)
|
||||
router.get('/housing-config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const city = req.query.city as string | undefined
|
||||
const where: any = { orgId: req.user!.orgId, isCurrent: true }
|
||||
if (city) where.city = city
|
||||
let config = await prisma.housingFundConfig.findFirst({
|
||||
where,
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
// 未指定城市时,返回任意当前配置
|
||||
if (!config && !city) {
|
||||
config = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
try {
|
||||
config = await prisma.housingFundConfig.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
city: city || '北京',
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
config = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, city: city || '北京' },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!config) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到公积金配置' } })
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金配置版本列表(支持按城市筛选)
|
||||
router.get('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const city = req.query.city as string | undefined
|
||||
const where: any = { orgId: req.user!.orgId }
|
||||
if (city) where.city = city
|
||||
const versions = await prisma.housingFundConfig.findMany({
|
||||
where,
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: versions })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 新建公积金配置版本
|
||||
const createHousingVersionSchema = z.object({
|
||||
...housingConfigFields,
|
||||
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
})
|
||||
|
||||
router.post('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = createHousingVersionSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const existing = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId, city: data.city, effectiveFrom: data.effectiveFrom },
|
||||
})
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有公积金配置版本` })
|
||||
}
|
||||
|
||||
const prevCurrent = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId, isCurrent: true },
|
||||
})
|
||||
if (prevCurrent) {
|
||||
const [year, mon] = data.effectiveFrom.split('-').map(Number)
|
||||
const prevMonth = mon === 1
|
||||
? `${year - 1}-12`
|
||||
: `${year}-${String(mon - 1).padStart(2, '0')}`
|
||||
await prisma.housingFundConfig.update({
|
||||
where: { id: prevCurrent.id },
|
||||
data: { isCurrent: false, effectiveTo: prevMonth },
|
||||
})
|
||||
}
|
||||
|
||||
const version = await prisma.housingFundConfig.create({
|
||||
data: {
|
||||
orgId,
|
||||
...data,
|
||||
isCurrent: true,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: version })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金计算
|
||||
router.post('/housing-calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { base, month, city } = calcSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
let config
|
||||
const whereBase: any = { orgId }
|
||||
if (city) whereBase.city = city
|
||||
if (month) {
|
||||
config = await prisma.housingFundConfig.findFirst({
|
||||
where: {
|
||||
...whereBase,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.housingFundConfig.findFirst({
|
||||
where: { ...whereBase, isCurrent: true },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.housingFundConfig.create({
|
||||
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), city: city || '北京', createdBy: req.user!.id },
|
||||
})
|
||||
}
|
||||
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
const housingOrg = actualBase * config.housingOrg / 100
|
||||
const housingEmp = actualBase * config.housingEmp / 100
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
actualBase,
|
||||
originalBase: base,
|
||||
capped: base > config.baseMax,
|
||||
floored: base < config.baseMin,
|
||||
configVersion: config.effectiveFrom,
|
||||
housingOrg,
|
||||
housingEmp,
|
||||
total: housingOrg + housingEmp,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金调基预览
|
||||
router.get('/housing-config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.housingFundConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' })
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE', city: config.city },
|
||||
select: { id: true, name: true, department: true, housingFundBase: true, monthlySalary: true },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
const lastYearStart = `${now.getFullYear() - 1}-01`
|
||||
const lastYearEnd = `${now.getFullYear() - 1}-12`
|
||||
|
||||
const lastYearPayslips = await prisma.payslip.findMany({
|
||||
where: { orgId, month: { gte: lastYearStart, lte: lastYearEnd } },
|
||||
select: { employeeId: true, totalPay: true },
|
||||
})
|
||||
|
||||
const empPayslipMap = new Map<string, number[]>()
|
||||
for (const p of lastYearPayslips) {
|
||||
if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, [])
|
||||
empPayslipMap.get(p.employeeId)!.push(p.totalPay)
|
||||
}
|
||||
|
||||
const items = employees.map((emp) => {
|
||||
let monthlyWage = 0
|
||||
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
|
||||
const oldBase = emp.housingFundBase ?? monthlyWage
|
||||
const payslips = empPayslipMap.get(emp.id)
|
||||
const avgSalary = payslips && payslips.length > 0 ? payslips.reduce((s, v) => s + v, 0) / payslips.length : monthlyWage
|
||||
const suggestedBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
|
||||
return {
|
||||
employeeId: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
oldBase,
|
||||
avgSalary,
|
||||
monthlyWage,
|
||||
suggestedBase,
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 执行公积金调基
|
||||
const adjustHousingSchema = z.object({
|
||||
items: z.array(z.object({
|
||||
employeeId: z.string(),
|
||||
newBase: z.number(),
|
||||
})),
|
||||
})
|
||||
|
||||
router.post('/housing-config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.housingFundConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' })
|
||||
|
||||
const { items } = adjustHousingSchema.parse(req.body)
|
||||
const adjustMonth = config.effectiveFrom
|
||||
const prevAdjustMonth = (() => {
|
||||
const [y, m] = adjustMonth.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
})()
|
||||
|
||||
let adjusted = 0
|
||||
for (const item of items) {
|
||||
const base = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax)
|
||||
|
||||
// 关闭旧记录
|
||||
await prisma.employeeHousingFundRecord.updateMany({
|
||||
where: { employeeId: item.employeeId, endMonth: null },
|
||||
data: { endMonth: prevAdjustMonth },
|
||||
})
|
||||
|
||||
// 创建新记录
|
||||
await prisma.employeeHousingFundRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: item.employeeId,
|
||||
city: config.city,
|
||||
startMonth: adjustMonth,
|
||||
endMonth: null,
|
||||
base,
|
||||
changeType: 'ADJUST',
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步 Employee 便捷字段
|
||||
await prisma.employee.update({
|
||||
where: { id: item.employeeId },
|
||||
data: { housingFundBase: base, housingFundStartMonth: adjustMonth },
|
||||
})
|
||||
adjusted++
|
||||
}
|
||||
|
||||
await prisma.housingFundConfig.update({
|
||||
where: { id },
|
||||
data: { adjustmentDone: true },
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { adjusted, total: items.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 重置公积金基数调整(撤销本次调整,重新来过)
|
||||
router.post('/housing-config/:id/reset-adjustment', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.housingFundConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
|
||||
if (!config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本尚未执行过基数调整,无需重置' })
|
||||
|
||||
// 恢复 adjustmentDone 标志
|
||||
await prisma.housingFundConfig.update({
|
||||
where: { id },
|
||||
data: { adjustmentDone: false },
|
||||
})
|
||||
|
||||
// 删除该版本创建的所有公积金记录变更
|
||||
await prisma.employeeHousingFundRecord.deleteMany({
|
||||
where: {
|
||||
orgId,
|
||||
changeType: 'ADJUST',
|
||||
startMonth: config.effectiveFrom,
|
||||
},
|
||||
})
|
||||
|
||||
// 恢复员工公积金基数为调整前
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
for (const emp of employees) {
|
||||
const prevRecord = await prisma.employeeHousingFundRecord.findFirst({
|
||||
where: { orgId, employeeId: emp.id, startMonth: { lt: config.effectiveFrom } },
|
||||
orderBy: { startMonth: 'desc' },
|
||||
})
|
||||
await prisma.employee.update({
|
||||
where: { id: emp.id },
|
||||
data: {
|
||||
housingFundBase: prevRecord?.base ?? null,
|
||||
housingFundStartMonth: prevRecord?.startMonth ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ success: true, message: '公积金基数调整已重置,可以重新调整' })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 月度增减员 ==========
|
||||
|
||||
// 社保月度增减员
|
||||
router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 增员:startMonth == month
|
||||
const additions = await prisma.employeeSocialInsRecord.findMany({
|
||||
where: { orgId, startMonth: month },
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
// 减员:endMonth == month 且 changeType == TERMINATION
|
||||
const reductions = await prisma.employeeSocialInsRecord.findMany({
|
||||
where: { orgId, endMonth: month, changeType: 'TERMINATION' },
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
month,
|
||||
additions: additions.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
reductions: reductions.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金月度增减员
|
||||
router.get('/housing/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const additions = await prisma.employeeHousingFundRecord.findMany({
|
||||
where: { orgId, startMonth: month },
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
const reductions = await prisma.employeeHousingFundRecord.findMany({
|
||||
where: { orgId, endMonth: month, changeType: 'TERMINATION' },
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
month,
|
||||
additions: additions.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
reductions: reductions.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 在职申报 ==========
|
||||
|
||||
// 社保在保人员
|
||||
router.get('/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const records = await prisma.employeeSocialInsRecord.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
startMonth: { lte: month },
|
||||
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
|
||||
},
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
month,
|
||||
items: records.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金在保人员
|
||||
router.get('/housing/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const records = await prisma.employeeHousingFundRecord.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
startMonth: { lte: month },
|
||||
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
|
||||
},
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
month,
|
||||
items: records.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,272 @@
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import { terminationChecklistSchema } from '../schemas/termination.schema'
|
||||
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems } from '../services/termination.service'
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
||||
const result = await getTerminations(req.user!.orgId, page, pageSize)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/checklist/:reason', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employeeId = req.query.employeeId as string
|
||||
let employee: any = undefined
|
||||
|
||||
if (employeeId) {
|
||||
const emp = await prisma.employee.findFirst({
|
||||
where: { id: employeeId, orgId: req.user!.orgId },
|
||||
include: {
|
||||
trainingRecords: true,
|
||||
},
|
||||
})
|
||||
if (emp) {
|
||||
employee = {
|
||||
isInMedicalPeriod: emp.isInMedicalPeriod,
|
||||
trainingRecords: emp.trainingRecords,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checklist = getChecklistForReason(req.params.reason, employee)
|
||||
res.json({ success: true, data: checklist })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/assess/:employeeId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: req.params.employeeId, orgId: req.user!.orgId } })
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
const assessment = assessRisk(employee, req.query.reason as string || '')
|
||||
res.json({ success: true, data: assessment })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = terminationChecklistSchema.parse(req.body)
|
||||
const result = await createTermination(req.user!.orgId, req.user!.id, data)
|
||||
await auditLog(req, 'TERMINATE', 'EMPLOYEE', data.employeeId, { reason: data.reason })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/resignation', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { employeeId, terminationDate, resignationReason, remark } = req.body
|
||||
if (!employeeId || !terminationDate) {
|
||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '缺少必填字段' } })
|
||||
}
|
||||
const result = await createResignation(req.user!.orgId, req.user!.id, { employeeId, terminationDate, resignationReason, remark })
|
||||
await auditLog(req, 'RESIGN', 'EMPLOYEE', employeeId, { resignationReason })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'CONFLICT') {
|
||||
return res.status(409).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id/revoke', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await revokeTermination(req.user!.orgId, req.params.id)
|
||||
await auditLog(req, 'REVOKE_TERMINATION', 'TERMINATION_RECORD', req.params.id, {})
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'CONFLICT') {
|
||||
return res.status(409).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
if (err?.code === 'NOT_FOUND') {
|
||||
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 批量解聘预检
|
||||
router.post('/batch/preview', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { items } = req.body as {
|
||||
items: Array<{ employeeId: string; reason: string; terminationDate: string }>
|
||||
}
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 items' } })
|
||||
}
|
||||
const results = await batchTerminatePreview(req.user!.orgId, items)
|
||||
res.json({ success: true, data: { total: results.length, warnings: results.filter(r => r.warnings.length > 0).length, results } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 批量解聘执行
|
||||
router.post('/batch', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { items } = req.body as {
|
||||
items: Array<{ employeeId: string; reason: string; terminationDate: string; compensation?: number }>
|
||||
}
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 items' } })
|
||||
}
|
||||
const result = await batchTerminate(req.user!.orgId, req.user!.id, items)
|
||||
for (const id of result.success) {
|
||||
await auditLog(req, 'TERMINATE', 'EMPLOYEE', id, { batch: true })
|
||||
}
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 解聘流程状态机 API
|
||||
// ============================================================
|
||||
|
||||
// 获取草稿/流程列表
|
||||
router.get('/drafts', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const status = req.query.status as string | undefined
|
||||
const result = await getDrafts(req.user!.orgId, status)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取单条记录详情
|
||||
router.get('/detail/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await getTerminationDetail(req.user!.orgId, req.params.id)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'NOT_FOUND') {
|
||||
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取默认工作交接清单模板
|
||||
router.get('/handover-template', authMiddleware, async (req: AuthRequest, res) => {
|
||||
res.json({ success: true, data: getDefaultHandoverItems() })
|
||||
})
|
||||
|
||||
// 创建草稿
|
||||
router.post('/draft', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await createDraft(req.user!.orgId, req.user!.id, req.body)
|
||||
await auditLog(req, 'CREATE_DRAFT', 'TERMINATION_RECORD', result.id, { reason: req.body.reason })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'NOT_FOUND') {
|
||||
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新草稿
|
||||
router.put('/draft/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await updateDraft(req.user!.orgId, req.params.id, req.user!.id, req.body)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
|
||||
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 提交审批
|
||||
router.post('/draft/:id/submit', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await submitForApproval(req.user!.orgId, req.params.id, req.user!.id)
|
||||
await auditLog(req, 'SUBMIT_TERMINATION', 'TERMINATION_RECORD', req.params.id, {})
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
|
||||
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 审批通过
|
||||
router.post('/draft/:id/approve', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { comment } = req.body
|
||||
const result = await approveTermination(req.user!.orgId, req.params.id, req.user!.id, comment || '')
|
||||
await auditLog(req, 'APPROVE_TERMINATION', 'TERMINATION_RECORD', req.params.id, { comment })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
|
||||
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 审批驳回
|
||||
router.post('/draft/:id/reject', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { comment } = req.body
|
||||
const result = await rejectTermination(req.user!.orgId, req.params.id, req.user!.id, comment || '')
|
||||
await auditLog(req, 'REJECT_TERMINATION', 'TERMINATION_RECORD', req.params.id, { comment })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
|
||||
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 执行解聘
|
||||
router.post('/draft/:id/execute', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await executeTermination(req.user!.orgId, req.params.id, req.user!.id)
|
||||
await auditLog(req, 'EXECUTE_TERMINATION', 'TERMINATION_RECORD', req.params.id, {})
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
|
||||
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 撤销
|
||||
router.post('/draft/:id/cancel', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await cancelTermination(req.user!.orgId, req.params.id, req.user!.id)
|
||||
await auditLog(req, 'CANCEL_TERMINATION', 'TERMINATION_RECORD', req.params.id, {})
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
|
||||
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user