feat: 完成优化1-6全部功能 — Portal安全/AI增强/Settings导入导出/社保公积金版本化
- 优化-1: 社保公积金独立配置+版本化缴费记录+多城市支持+迁移脚本 - 优化-2: AI流式输出/RAG集成/风险角标/审计日志/二维码/批量续签/忘记密码/语音输入/PDF导出/速率限制/套餐人数上限 - 优化-3: 批次重命名/费用实时预览/模拟版本管理/续签合规预检/社保重置/搜索分页/批量解聘/到期预警/税率试算 - 优化-4: 会话历史/待办批量/结果关联档案/风险下钻/预测上下文/附件校验/Tab级联/薪税导出 - 优化-5: 表单回填/用户编辑禁用/导入预览/选择性导出/通知测试/错误日志导出/脱敏导出/gzip压缩 - 优化-6: 工资条确认通知HR/AI上下文增强/电子签名/用量限制修复/入职文件上传/RAG管理/工资趋势/用量事务/验证码加固/审查结构化/链接撤回/超时机制/确认重发/案例转待办
This commit is contained in:
@@ -98,6 +98,7 @@ enum OnboardingStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum ContractConfirmStatus {
|
||||
@@ -157,6 +158,7 @@ model User {
|
||||
passwordHash String
|
||||
name String
|
||||
role Role @default(ADMIN)
|
||||
disabled Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
lastLoginAt DateTime?
|
||||
}
|
||||
@@ -789,3 +791,18 @@ model AIReviewRecord {
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
}
|
||||
|
||||
// ========== RAG 知识库 ==========
|
||||
|
||||
model RagKnowledge {
|
||||
id String @id
|
||||
title String
|
||||
content String
|
||||
source String
|
||||
category String
|
||||
embedding Unsupported("vector(1536)")?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([category])
|
||||
@@map("rag_knowledge")
|
||||
}
|
||||
|
||||
@@ -19,12 +19,13 @@ async function checkUsageLimit(orgId: string, type: 'chat' | 'review' | 'case'):
|
||||
const limits = PLAN_LIMITS[org.plan] || PLAN_LIMITS.FREE
|
||||
const limit = limits[type]
|
||||
if (limit === 0) return
|
||||
const month = new Date().toISOString().slice(0, 7)
|
||||
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()}`,
|
||||
detail: JSON.stringify({ month }) as any,
|
||||
createdAt: { gte: monthStart },
|
||||
},
|
||||
})
|
||||
if (count >= limit) {
|
||||
@@ -59,12 +60,20 @@ async function buildOrgContext(orgId: string): Promise<string> {
|
||||
}),
|
||||
])
|
||||
|
||||
const now = new Date()
|
||||
const empSummary = employees.map((e) => {
|
||||
const contract = e.contracts[0]
|
||||
return `- ${e.name}(${e.department}),入职${e.hireDate.toISOString().slice(0, 10)},${contract ? `合同类型:${contract.contractType}` : '未签合同'}`
|
||||
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})`).join('\n')
|
||||
const riskSummary = risks.map((r) => `- ${r.title}(${r.level}):${r.description || '无详细描述'}`).join('\n')
|
||||
|
||||
return `员工列表(${employees.length}人):
|
||||
${empSummary}
|
||||
@@ -100,12 +109,19 @@ router.post('/chat-stream', authMiddleware, async (req: AuthRequest, res, next)
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
for await (const delta of chatStream(messages, orgContext)) {
|
||||
res.write(`data: ${JSON.stringify({ delta })}\n\n`)
|
||||
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.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
|
||||
} catch (err) {
|
||||
if (!res.headersSent) next(err)
|
||||
else res.end()
|
||||
@@ -121,7 +137,7 @@ router.post('/review', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
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: { result } })
|
||||
res.json({ success: true, data: { text: result.text, structured: result.structured } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
@@ -142,6 +158,34 @@ router.post('/match-case', authMiddleware, async (req: AuthRequest, res, next) =
|
||||
}
|
||||
})
|
||||
|
||||
// 案例匹配结果转待办(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 scope = (req.query.scope as string) || 'all'
|
||||
@@ -330,4 +374,31 @@ router.post('/rag/search', authMiddleware, async (req: AuthRequest, res, next) =
|
||||
}
|
||||
})
|
||||
|
||||
// 知识库列表
|
||||
router.get('/rag/list', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const category = req.query.category as string | undefined
|
||||
const items = await prisma.$queryRaw`
|
||||
SELECT id, title, content, source, category, created_at
|
||||
FROM rag_knowledge
|
||||
${category ? prisma.$queryRaw`WHERE category = ${category}` : prisma.$queryRaw``}
|
||||
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 prisma.$executeRaw`DELETE FROM rag_knowledge WHERE id = ${req.params.id}`
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -3,48 +3,155 @@ 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 [employees, contracts, terminations, payrollBatches, payslips, socialRecords, housingRecords, riskItems] = await Promise.all([
|
||||
prisma.employee.findMany({ where: { orgId } }),
|
||||
prisma.laborContract.findMany({ where: { orgId } }),
|
||||
prisma.terminationRecord.findMany({ where: { orgId } }),
|
||||
prisma.payrollBatch.findMany({ where: { orgId } }),
|
||||
prisma.payslip.findMany({ where: { orgId } }),
|
||||
prisma.employeeSocialInsRecord.findMany({ where: { orgId } }),
|
||||
prisma.employeeHousingFundRecord.findMany({ where: { orgId } }),
|
||||
prisma.riskItem.findMany({ where: { orgId } }),
|
||||
])
|
||||
|
||||
const safeEmployees = employees.map((e) => {
|
||||
let salary = 0
|
||||
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
|
||||
let idCard = null
|
||||
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
|
||||
return { ...e, monthlySalary: salary, idCardNumber: idCard }
|
||||
})
|
||||
|
||||
const data = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
orgId,
|
||||
employees: safeEmployees,
|
||||
contracts,
|
||||
terminations,
|
||||
payrollBatches,
|
||||
payslips,
|
||||
socialRecords,
|
||||
housingRecords,
|
||||
riskItems,
|
||||
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 } }),
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json"`)
|
||||
res.json(data)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,54 @@ 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')}`
|
||||
}
|
||||
@@ -35,6 +83,133 @@ function num(v: any): number {
|
||||
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 orgId = req.user!.orgId
|
||||
|
||||
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: '请上传文件' } })
|
||||
@@ -58,14 +233,21 @@ router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthReq
|
||||
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: val(r['身份证号']) ? encrypt(val(r['身份证号'])) : null,
|
||||
idCardHash: val(r['身份证号']) ? sha256(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,
|
||||
@@ -147,9 +329,12 @@ router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthReq
|
||||
const date = parseDate(r['日期'])
|
||||
if (!date) continue
|
||||
const month = dateToMonth(date)
|
||||
const hours = num(r['加班时长'])
|
||||
const otType = val(r['加班类型']) || '工作日加班'
|
||||
await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours: otType.includes('工作日') ? hours : 0, weekendHours: otType.includes('休息日') ? hours : 0, holidayHours: otType.includes('法定') ? hours : 0, createdBy: userId } as any })
|
||||
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++
|
||||
}
|
||||
}
|
||||
@@ -214,7 +399,7 @@ router.get('/template', authMiddleware, async (req: AuthRequest, res: Response)
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同')
|
||||
|
||||
const otData = [
|
||||
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '加班时长': 2, '加班类型': '工作日加班', '倍率': 1.5, '是否审批': '是' },
|
||||
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
|
||||
|
||||
@@ -247,7 +432,7 @@ router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthR
|
||||
}
|
||||
|
||||
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[] }
|
||||
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]))
|
||||
@@ -298,13 +483,16 @@ router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthR
|
||||
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: otType.includes('工作日') ? hours : 0, weekendHours: otType.includes('休息日') ? hours : 0, holidayHours: otType.includes('法定') ? hours : 0 } as any,
|
||||
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours } as any,
|
||||
update: {
|
||||
weekdayHours: { increment: otType.includes('工作日') ? hours : 0 },
|
||||
weekendHours: { increment: otType.includes('休息日') ? hours : 0 },
|
||||
holidayHours: { increment: otType.includes('法定') ? hours : 0 },
|
||||
weekdayHours: { increment: wdHours },
|
||||
weekendHours: { increment: weHours },
|
||||
holidayHours: { increment: hoHours },
|
||||
},
|
||||
})
|
||||
result.overtime++
|
||||
@@ -347,7 +535,10 @@ router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthR
|
||||
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 } })
|
||||
@@ -397,7 +588,7 @@ router.get('/monthly-template', authMiddleware, async (req: AuthRequest, res: Re
|
||||
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, '加班类型': '工作日加班' }]
|
||||
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', '调薪原因': '年度调薪' }]
|
||||
|
||||
@@ -131,4 +131,39 @@ router.post('/check-contracts', async (req: AuthRequest, res: Response, next: Ne
|
||||
}
|
||||
})
|
||||
|
||||
// 测试通知渠道
|
||||
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
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
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 } from '../schemas/portal.schema'
|
||||
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 验证码临时存储(生产环境应使用 Redis)
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number; failCount: number; lastSentAt: number }>()
|
||||
|
||||
// 员工端认证中间件
|
||||
function portalAuth(req: Request, res: Response, next: NextFunction) {
|
||||
@@ -59,8 +62,13 @@ router.post('/send-code', async (req, res, next) => {
|
||||
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 })
|
||||
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)
|
||||
@@ -75,8 +83,14 @@ router.post('/verify-code', async (req, res, next) => {
|
||||
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) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: '验证码错误' } })
|
||||
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' } })
|
||||
@@ -106,11 +120,26 @@ router.get('/payslip', portalAuth, async (req: any, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 工资条历史(最近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: '工资条不存在' } })
|
||||
@@ -119,6 +148,16 @@ router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => {
|
||||
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)
|
||||
@@ -176,23 +215,68 @@ router.post('/onboarding', async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 合同签署验证码发送
|
||||
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()}` },
|
||||
data: { attachmentName: `confirmed:${new Date().toISOString()}|evidence:${signEvidence}` },
|
||||
})
|
||||
res.json({ success: true, data: { message: '合同签署确认成功' } })
|
||||
} catch (err) {
|
||||
@@ -216,6 +300,28 @@ router.get('/onboarding/:token', async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 撤回入职链接(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 {
|
||||
@@ -245,4 +351,75 @@ router.get('/contract-confirm/:token', async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 重发合同确认链接(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
|
||||
|
||||
@@ -11,6 +11,7 @@ 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({
|
||||
@@ -56,7 +57,7 @@ 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, createdAt: true },
|
||||
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 })
|
||||
@@ -97,7 +98,7 @@ router.put('/users/:id', async (req: AuthRequest, res, next) => {
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: data,
|
||||
select: { id: true, name: true, phone: true, role: true },
|
||||
select: { id: true, name: true, phone: true, email: true, role: true, disabled: true },
|
||||
})
|
||||
res.json({ success: true, data: user })
|
||||
} catch (err) {
|
||||
@@ -118,4 +119,70 @@ router.delete('/users/:id', async (req: AuthRequest, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 禁用/启用用户
|
||||
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
|
||||
|
||||
@@ -29,4 +29,9 @@ export const onboardingSchema = z.object({
|
||||
export const contractConfirmSchema = z.object({
|
||||
token: z.string().min(1, '缺少 token'),
|
||||
agreed: z.boolean().refine((v) => v === true, '请勾选确认签署'),
|
||||
verifyCode: z.string().length(6, '验证码为6位数字'),
|
||||
})
|
||||
|
||||
export const contractSendCodeSchema = z.object({
|
||||
token: z.string().min(1, '缺少 token'),
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { searchKnowledge } from './rag.service'
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY || ''
|
||||
const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
|
||||
const client = new OpenAI({ apiKey, baseURL })
|
||||
const client = new OpenAI({ apiKey, baseURL, timeout: 30 * 1000, maxRetries: 1 })
|
||||
|
||||
const SYSTEM_PROMPT = `你是一个专业的劳动用工合规顾问,精通中国劳动法、劳动合同法、社会保险法等相关法律法规。
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function* chatStream(messages: { role: 'user' | 'assistant'; conten
|
||||
}
|
||||
}
|
||||
|
||||
export async function reviewContract(contractText: string) {
|
||||
export async function reviewContract(contractText: string): Promise<{ text: string; structured: { riskItems: { level: string; title: string; description: string; suggestion: string }[]; score: number; summary: string } }> {
|
||||
const prompt = `请审查以下劳动合同文本的合法性,逐条检查并标注风险等级(红/黄/绿),给出修改建议,最后给出合规评分(0-100分)。
|
||||
|
||||
合同文本:
|
||||
@@ -108,7 +108,28 @@ ${contractText}
|
||||
max_tokens: 3000,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message?.content || ''
|
||||
const text = response.choices[0]?.message?.content || ''
|
||||
|
||||
// 解析结构化数据
|
||||
const riskItems: { level: string; title: string; description: string; suggestion: string }[] = []
|
||||
const riskRegex = /(🔴|🟡|🟢)\s*\[([^\]]+)\]\s*-\s*\[([^\]]+)\]\s*-\s*\[([^\]]+)\]/g
|
||||
let match
|
||||
while ((match = riskRegex.exec(text)) !== null) {
|
||||
riskItems.push({
|
||||
level: match[1] === '🔴' ? 'RED' : match[1] === '🟡' ? 'YELLOW' : 'GREEN',
|
||||
title: match[2],
|
||||
description: match[3],
|
||||
suggestion: match[4],
|
||||
})
|
||||
}
|
||||
|
||||
const scoreMatch = text.match(/【合规评分】\s*(\d+)\s*\/\s*100/)
|
||||
const score = scoreMatch ? parseInt(scoreMatch[1]) : 0
|
||||
|
||||
const summaryMatch = text.match(/【总体建议】\s*([\s\S]*?)(?:$|$)/)
|
||||
const summary = summaryMatch ? summaryMatch[1].trim() : ''
|
||||
|
||||
return { text, structured: { riskItems, score, summary } }
|
||||
}
|
||||
|
||||
export async function matchCase(scenario: string) {
|
||||
|
||||
@@ -53,6 +53,10 @@ export async function login(phone: string, password: string) {
|
||||
throw { code: 'AUTH_FAILED', message: '手机号或密码错误' }
|
||||
}
|
||||
|
||||
if (user.disabled) {
|
||||
throw { code: 'ACCOUNT_DISABLED', message: '该账号已被禁用,请联系管理员' }
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
|
||||
Reference in New Issue
Block a user