fix: portal端token过期后自动跳转登录页,避免显示空数据

- portalAxios 401 响应拦截器:清除 token 并跳转 /portal/login
- PortalLayoutWrapper:检查 token 是否存在,不存在则跳转登录页
- 排除 /portal/login 和 /portal/auto-login 页面避免重复跳转

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-17 15:06:46 +08:00
parent c66ea290eb
commit 5196d7c80a
23 changed files with 812 additions and 74 deletions
+8 -1
View File
@@ -240,7 +240,9 @@ model Employee {
// 组织架构扩展
departmentId String? // 关联 Department 表(兼容旧 department 字符串)
supervisorId String? // 直属上级员工ID
monthlySalary String // AES-256 加密存储
monthlySalary String // AES-256 加密存储(月薪总额 = 基本工资 + 绩效工资)
baseSalary String? // 基本工资(AES-256 加密,null=旧数据未拆分)
performanceSalary String? // 绩效工资(AES-256 加密,可为"0"null=旧数据未拆分)
status EmployeeStatus @default(ACTIVE)
gender String?
phone String?
@@ -348,6 +350,8 @@ model LaborContract {
contractYears Int @default(3)
probationMonths Int @default(0)
probationSalary Int @default(0)
baseSalary Int @default(0) // 基本工资(转正后)
performanceSalary Int @default(0) // 绩效工资(转正后,可为0)
renewalCount Int @default(0)
attachmentName String?
attachmentUrl String?
@@ -713,6 +717,7 @@ model DisciplinaryRecord {
severity String @default("WARNING") // WARNING/SERIOUS/SEVERE
action String @default("ORAL_WARNING") // ORAL_WARNING/WRITTEN_WARNING/DEDUCTION/DEMOTION/TERMINATION
actionDetail String?
deductionAmount Float @default(0) // 扣款金额(action=DEDUCTION 时填写,用于薪资批次导入)
employeeAck Boolean @default(false) // 员工是否签字确认
ackDate DateTime?
ackMethod String? // SIGN/ELECTRONIC/REFUSED
@@ -820,6 +825,7 @@ model Payslip {
month String // YYYY-MM
// 薪酬构成
baseSalary Float @default(0)
performanceSalary Float @default(0) // 绩效工资
overtimePay Float @default(0)
weekdayOvertimePay Float @default(0)
weekendOvertimePay Float @default(0)
@@ -1194,6 +1200,7 @@ model AttendanceConfirmation {
weekendHours Float @default(0)
holidayHours Float @default(0)
overtimePay Float @default(0)
deductionAmount Float @default(0) // 考勤扣款金额(迟到/早退/旷工等,手动填写,用于薪资批次导入)
confirmedAt DateTime?
confirmedBy String?
confirmIp String?
+17
View File
@@ -67,6 +67,7 @@ router.post('/batch', authMiddleware, async (req: AuthRequest, res: Response, ne
weekendHours: z.number().min(0),
holidayHours: z.number().min(0),
overtimePay: z.number().min(0),
deductionAmount: z.number().min(0).optional(),
})),
})
const { month, items } = schema.parse(req.body)
@@ -126,6 +127,22 @@ router.post('/batch-confirm', authMiddleware, async (req: AuthRequest, res: Resp
} catch (err) { next(err) }
})
/** 更新考勤确认的扣款金额 */
router.put('/confirmations/:id/deduction', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { deductionAmount } = req.body
const record = await prisma.attendanceConfirmation.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '考勤确认记录不存在' } })
const updated = await prisma.attendanceConfirmation.update({
where: { id: req.params.id },
data: { deductionAmount: Number(deductionAmount) || 0 },
})
res.json({ success: true, data: updated })
} catch (err) { next(err) }
})
// ========== 班次管理 ==========
router.get('/shifts', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
+332 -2
View File
@@ -366,16 +366,28 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
const failedEmployees: { employeeId: string; name: string; error: string }[] = []
for (const emp of employees) {
let baseSalary = 0
let performanceSalary = 0
let overtimePay = 0
let allowance = 0
let deduction = 0
let bonus = 0
// 解密员工工资结构
let empBaseSalary = 0
let empPerformanceSalary = 0
if (emp.baseSalary) {
try { empBaseSalary = Number(decrypt(emp.baseSalary)) || 0 } catch { empBaseSalary = Number(emp.baseSalary) || 0 }
}
if (emp.performanceSalary) {
try { empPerformanceSalary = Number(decrypt(emp.performanceSalary)) || 0 } catch { empPerformanceSalary = Number(emp.performanceSalary) || 0 }
}
if (mode === 'copy_batch' && sourceEntries) {
// 复制指定批次:从源条目复制数据
const srcEntry = sourceEntries.find(e => e.employeeId === emp.id)
if (srcEntry) {
baseSalary = srcEntry.baseSalary
performanceSalary = srcEntry.performanceSalary || 0
overtimePay = srcEntry.overtimePay
allowance = srcEntry.allowance
deduction = srcEntry.deduction
@@ -397,17 +409,40 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
const inProbation = isInProbation(latestContract, batchMonthEnd)
if (inProbation && latestContract.probationSalary > 0) {
baseSalary = latestContract.probationSalary
performanceSalary = 0 // 试用期一般不发绩效
} else if (prevPayslip) {
// 非试用期:优先用上月工资条的基本工资(保持薪资连续性)
baseSalary = prevPayslip.baseSalary
performanceSalary = prevPayslip.performanceSalary || 0
} else if (empBaseSalary > 0 || empPerformanceSalary > 0) {
// 新数据:有工资结构
baseSalary = empBaseSalary
performanceSalary = empPerformanceSalary
} else if (emp.monthlySalary) {
// 旧数据兼容:回退到 monthlySalary
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
}
overtimePay = overtime?.totalPay || 0
allowance = prevPayslip?.allowance || 0
deduction = prevPayslip?.deduction || 0
} else if (mode === 'custom' || mode === 'blank_employees') {
// 自定义/空白员工模式:取员工工资结构(非试用期)
const batchMonthEnd = new Date(`${month}-28T23:59:59`)
const latestContract = emp.contracts?.[0]
const inProbation = isInProbation(latestContract, batchMonthEnd)
if (inProbation && latestContract?.probationSalary > 0) {
baseSalary = latestContract.probationSalary
performanceSalary = 0
} else if (empBaseSalary > 0 || empPerformanceSalary > 0) {
baseSalary = empBaseSalary
performanceSalary = empPerformanceSalary
} else if (emp.monthlySalary && mode === 'custom') {
// custom 模式旧数据兼容
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
}
// blank_employees 模式:金额默认0,不自动带出
}
// blank_employees 和 blank_all: 所有金额默认 0(不自动带出基本工资,不取试用期工资)
// blank_all: 所有金额默认 0
// 统一试用期判定(仅 copy_last / copy_batch / custom 模式适用,SEVERANCE 除外)
// blank_employees / blank_all 模式下所有金额应为 0,不覆盖试用期工资
@@ -416,6 +451,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
const latestContract = emp.contracts?.[0]
if (isInProbation(latestContract, batchMonthEnd) && latestContract?.probationSalary > 0) {
baseSalary = latestContract.probationSalary
performanceSalary = 0
}
}
@@ -442,7 +478,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
try {
// 查询上月递延数据(上月已归档批次中该员工的递延金额)
const prevDeferred = await getPrevDeferred(orgId, emp.id, month)
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { prevDeferred })
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, performanceSalary, overtimePay, allowance, deduction, bonus }, type, { prevDeferred })
} catch (calcErr: any) {
// 单个员工计算失败不阻塞整个批次,记录错误并使用零值
failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' })
@@ -459,6 +495,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
orgId,
employeeId: emp.id,
baseSalary,
performanceSalary,
overtimePay,
allowance,
deduction,
@@ -692,6 +729,299 @@ router.post('/batches/:batchId/fetch-bonus', async (req: AuthRequest, res: Respo
}
})
// 获取绩效工资:按批次月份从 PerformanceRecord 查考核等级,结合 Employee.performanceSalary 计算绩效工资
// 绩效系数:A=1.2, B=1.0, C=0.8, D=0.6;无绩效记录则系数=1.0
router.post('/batches/:batchId/fetch-performance', 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: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
// 获取批次所有条目
const entries = await prisma.batchEntry.findMany({
where: { batchId },
select: { id: true, employeeId: true, performanceSalary: true },
})
if (entries.length === 0) {
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
}
// 查询批次月份的绩效记录(按 period = YYYY-MM 匹配)
const performanceRecords = await prisma.performanceRecord.findMany({
where: { orgId, period: batch.month, periodType: 'MONTHLY' },
select: { employeeId: true, grade: true, score: true, result: true },
})
const perfMap = new Map<string, { grade: string; score: number }>()
for (const r of performanceRecords) {
// 同一员工同月可能有多条记录,取最新一条(按创建时间倒序已由 DB 保证)
if (!perfMap.has(r.employeeId)) {
perfMap.set(r.employeeId, { grade: r.grade, score: r.score })
}
}
// 查询员工绩效工资基数
const employees = await prisma.employee.findMany({
where: { id: { in: entries.map(e => e.employeeId) } },
select: { id: true, performanceSalary: true },
})
const empPerfSalaryMap = new Map<string, number>()
for (const emp of employees) {
if (emp.performanceSalary) {
try { empPerfSalaryMap.set(emp.id, Number(decrypt(emp.performanceSalary)) || 0) } catch {}
}
}
// 绩效系数映射
const gradeCoefficients: Record<string, number> = { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
let filled = 0
let totalAmount = 0
for (const entry of entries) {
const basePerfSalary = empPerfSalaryMap.get(entry.employeeId) || 0
if (basePerfSalary <= 0) continue // 绩效工资基数为0则跳过
const perfRecord = perfMap.get(entry.employeeId)
const coefficient = perfRecord ? (gradeCoefficients[perfRecord.grade] || 1.0) : 1.0
const calculatedPerfSalary = Math.round(basePerfSalary * coefficient * 100) / 100
await prisma.batchEntry.update({
where: { id: entry.id },
data: { performanceSalary: calculatedPerfSalary },
})
filled++
totalAmount += calculatedPerfSalary
}
// 重算批次汇总
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
const totals = allEntries.reduce((acc, e) => ({
totalPay: acc.totalPay + e.baseSalary + (e.performanceSalary || 0) + (e.positionSalary || 0) + (e.senioritySalary || 0) + e.overtimePay + (e.transportAllowance || 0) + (e.mealAllowance || 0) + (e.housingAllowance || 0) + (e.communicationAllowance || 0) + e.allowance + e.bonus - e.deduction - (e.otherDeduction || 0),
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 })
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: {
filled,
totalAmount: Math.round(totalAmount * 100) / 100,
message: filled > 0
? `已填充 ${filled} 人绩效工资,合计 ¥${Math.round(totalAmount * 100) / 100}${performanceRecords.length > 0 ? `${performanceRecords.length} 人有考核记录)` : '(无考核记录,按系数1.0计算)'}`
: '无绩效工资数据(员工未设置绩效工资基数)',
},
})
} catch (err) {
next(err)
}
})
// 获取违纪扣款:按批次月份从 DisciplinaryRecord 查 action=DEDUCTION 的记录,汇总扣款金额填充到 entries.deduction
router.post('/batches/:batchId/fetch-disciplinary', 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: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
// 获取批次所有条目
const entries = await prisma.batchEntry.findMany({
where: { batchId },
select: { id: true, employeeId: true, deduction: true },
})
if (entries.length === 0) {
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
}
// 查询批次月份内的违纪扣款记录(按 violationDate 落在批次月份内)
const monthStart = new Date(batch.month + '-01')
const monthEnd = new Date(monthStart)
monthEnd.setMonth(monthEnd.getMonth() + 1)
const disciplinaryRecords = await prisma.disciplinaryRecord.findMany({
where: {
orgId,
employeeId: { in: entries.map(e => e.employeeId) },
action: 'DEDUCTION',
deductionAmount: { gt: 0 },
violationDate: { gte: monthStart, lt: monthEnd },
},
select: { employeeId: true, deductionAmount: true },
})
// 按员工汇总扣款金额
const deductionMap = new Map<string, number>()
for (const r of disciplinaryRecords) {
deductionMap.set(r.employeeId, (deductionMap.get(r.employeeId) || 0) + r.deductionAmount)
}
let filled = 0
let totalAmount = 0
for (const entry of entries) {
const deduction = deductionMap.get(entry.employeeId)
if (deduction && deduction > 0) {
await prisma.batchEntry.update({
where: { id: entry.id },
data: { deduction: Math.round(deduction * 100) / 100 },
})
filled++
totalAmount += deduction
}
}
// 重算批次汇总
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
const totals = allEntries.reduce((acc, e) => ({
totalPay: acc.totalPay + e.baseSalary + (e.performanceSalary || 0) + (e.positionSalary || 0) + (e.senioritySalary || 0) + e.overtimePay + (e.transportAllowance || 0) + (e.mealAllowance || 0) + (e.housingAllowance || 0) + (e.communicationAllowance || 0) + e.allowance + e.bonus - e.deduction - (e.otherDeduction || 0),
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 })
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: {
filled,
totalAmount: Math.round(totalAmount * 100) / 100,
message: filled > 0
? `已填充 ${filled} 人违纪扣款,合计 ¥${Math.round(totalAmount * 100) / 100}`
: `${batch.month} 无违纪扣款数据`,
},
})
} catch (err) {
next(err)
}
})
// 获取考勤扣款:按批次月份从 AttendanceConfirmation 查 deductionAmount > 0 的记录,填充到 entries.deduction
router.post('/batches/:batchId/fetch-attendance-deduction', 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: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
// 获取批次所有条目
const entries = await prisma.batchEntry.findMany({
where: { batchId },
select: { id: true, employeeId: true, deduction: true },
})
if (entries.length === 0) {
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
}
// 查询批次月份的考勤确认记录
const confirmations = await prisma.attendanceConfirmation.findMany({
where: {
orgId,
employeeId: { in: entries.map(e => e.employeeId) },
month: batch.month,
deductionAmount: { gt: 0 },
},
select: { employeeId: true, deductionAmount: true },
})
const deductionMap = new Map<string, number>()
for (const c of confirmations) {
deductionMap.set(c.employeeId, c.deductionAmount)
}
let filled = 0
let totalAmount = 0
for (const entry of entries) {
const deduction = deductionMap.get(entry.employeeId)
if (deduction && deduction > 0) {
await prisma.batchEntry.update({
where: { id: entry.id },
data: { deduction: Math.round(deduction * 100) / 100 },
})
filled++
totalAmount += deduction
}
}
// 重算批次汇总
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
const totals = allEntries.reduce((acc, e) => ({
totalPay: acc.totalPay + e.baseSalary + (e.performanceSalary || 0) + (e.positionSalary || 0) + (e.senioritySalary || 0) + e.overtimePay + (e.transportAllowance || 0) + (e.mealAllowance || 0) + (e.housingAllowance || 0) + (e.communicationAllowance || 0) + e.allowance + e.bonus - e.deduction - (e.otherDeduction || 0),
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 })
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: {
filled,
totalAmount: Math.round(totalAmount * 100) / 100,
message: filled > 0
? `已填充 ${filled} 人考勤扣款,合计 ¥${Math.round(totalAmount * 100) / 100}`
: `${batch.month} 无考勤扣款数据(需先在考勤确认中填写扣款金额)`,
},
})
} catch (err) {
next(err)
}
})
// 获取条目个税计算明细
router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
+15 -6
View File
@@ -220,6 +220,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
idCardMasked,
idCardNumber: safeDecryptStr(e.idCardNumber),
monthlySalary: safeDecrypt(e.monthlySalary),
baseSalary: e.baseSalary ? safeDecrypt(e.baseSalary) : null,
performanceSalary: e.performanceSalary ? safeDecrypt(e.performanceSalary) : null,
socialInsBase: e.socialInsBase,
housingFundBase: e.housingFundBase,
socialInsCalc: (() => {
@@ -373,7 +375,7 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
if (found) monthlyProcessRecords.push(recordData)
}
const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee
const { monthlySalary, baseSalary, performanceSalary, bankAccount, idCardNumber, ...rest } = employee
const today = new Date()
today.setHours(0, 0, 0, 0)
const dynamicStatus = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE'
@@ -383,6 +385,8 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
...rest,
status: dynamicStatus,
monthlySalary: safeDecrypt(monthlySalary),
baseSalary: baseSalary ? safeDecrypt(baseSalary) : null,
performanceSalary: performanceSalary ? safeDecrypt(performanceSalary) : null,
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
idCardNumber: safeDecryptStr(idCardNumber),
monthlyProcessRecords,
@@ -961,7 +965,7 @@ router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest,
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 { violationDate, violationType, description, severity, action, actionDetail, deductionAmount, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
const record = await prisma.disciplinaryRecord.create({
data: {
orgId: req.user!.orgId,
@@ -972,6 +976,7 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest
severity: severity || 'WARNING',
action: action || 'ORAL_WARNING',
actionDetail,
deductionAmount: Number(deductionAmount) || 0,
employeeAck: employeeAck || false,
ackDate: ackDate ? new Date(ackDate) : null,
ackMethod,
@@ -995,7 +1000,7 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest
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 { violationDate, violationType, description, severity, action, actionDetail, deductionAmount, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
const record = await prisma.disciplinaryRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
})
@@ -1009,6 +1014,7 @@ router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: Au
severity,
action,
actionDetail,
deductionAmount: deductionAmount !== undefined ? Number(deductionAmount) || 0 : undefined,
employeeAck,
ackDate: ackDate ? new Date(ackDate) : null,
ackMethod,
@@ -1412,7 +1418,7 @@ function prevMonth(month: string): string {
// 调薪
router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { newSalary, effectiveMonth, reason } = req.body
const { newSalary, baseSalary, performanceSalary, effectiveMonth, reason } = req.body
const employee = await prisma.employee.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
@@ -1446,10 +1452,13 @@ router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res,
},
})
// 同步 Employee 便捷字段
// 同步 Employee 便捷字段(含工资结构)
const updateData: any = { monthlySalary: encrypt(String(newSalary)) }
if (baseSalary !== undefined) updateData.baseSalary = encrypt(String(Number(baseSalary) || 0))
if (performanceSalary !== undefined) updateData.performanceSalary = encrypt(String(Number(performanceSalary) || 0))
await prisma.employee.update({
where: { id: req.params.id },
data: { monthlySalary: encrypt(String(newSalary)) },
data: updateData,
})
await auditLog(req, 'CREATE', 'SALARY_CHANGE', record.id, { employeeId: req.params.id, oldSalary, newSalary })
@@ -48,6 +48,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
weekendHours: number
holidayHours: number
overtimePay: number
deductionAmount?: number
}>) {
const results: Array<{ employeeId: string; success: boolean; error?: string }> = []
@@ -66,6 +67,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
weekendHours: item.weekendHours,
holidayHours: item.holidayHours,
overtimePay: item.overtimePay,
deductionAmount: item.deductionAmount || 0,
status: 'PENDING',
},
})
@@ -80,6 +82,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
weekendHours: item.weekendHours,
holidayHours: item.holidayHours,
overtimePay: item.overtimePay,
deductionAmount: item.deductionAmount || 0,
createdBy: userId,
},
})
+46
View File
@@ -14,6 +14,15 @@ function dateToMonth(date: Date): string {
return `${y}-${m}`
}
/** 安全解密数字(工资字段) */
function safeDecryptNum(encrypted: string): number {
try {
return Number(decrypt(encrypted)) || 0
} catch {
return Number(encrypted) || 0
}
}
async function clampSocialInsBase(orgId: string, base: number, city?: string, accountId?: string): Promise<number> {
// 优先按账户查年度标准
if (accountId) {
@@ -271,6 +280,9 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
} catch {
decryptedSalary = Number(emp.monthlySalary) || 0
}
// 解密工资结构
const decryptedBaseSalary = emp.baseSalary ? safeDecryptNum(emp.baseSalary) : null
const decryptedPerformanceSalary = emp.performanceSalary ? safeDecryptNum(emp.performanceSalary) : null
return {
id: emp.id,
@@ -279,6 +291,8 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
hireDate: emp.hireDate.toISOString().slice(0, 10),
status: emp.status,
monthlySalary: decryptedSalary,
baseSalary: decryptedBaseSalary,
performanceSalary: decryptedPerformanceSalary,
contractStatus: contractInfo.status,
contractStatusText: contractInfo.statusText,
riskLevel: contractInfo.riskLevel,
@@ -315,6 +329,8 @@ export async function getEmployeeDetail(orgId: string, id: string) {
return {
...employee,
monthlySalary: decryptedSalary,
baseSalary: employee.baseSalary ? safeDecryptNum(employee.baseSalary) : null,
performanceSalary: employee.performanceSalary ? safeDecryptNum(employee.performanceSalary) : null,
}
}
@@ -368,6 +384,9 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
const hireDate = new Date(data.hireDate)
const hireMonth = dateToMonth(hireDate)
const salaryNum = Number(data.monthlySalary) || 0
// 工资结构:基本工资 + 绩效工资 = 月薪总额
const baseSalaryNum = Number(data.baseSalary) || 0
const performanceSalaryNum = Number(data.performanceSalary) || 0
const city = data.city || '北京'
// 劳务协议/实习协议:不缴纳社保公积金,基数强制 0
const isNoSocialContract = data.contract && ['LABOR', 'INTERNSHIP'].includes(data.contract.contractType)
@@ -389,6 +408,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
department: data.department,
hireDate,
monthlySalary: encrypt(data.monthlySalary),
baseSalary: encrypt(String(baseSalaryNum)),
performanceSalary: encrypt(String(performanceSalaryNum)),
gender: data.gender,
femaleWorkerType: data.femaleWorkerType,
phone: data.phone,
@@ -488,6 +509,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
contractYears: data.contract.contractYears || 3,
probationMonths: data.contract.probationMonths || 0,
probationSalary: data.contract.probationSalary || 0,
baseSalary: baseSalaryNum,
performanceSalary: performanceSalaryNum,
createdBy: userId,
},
})
@@ -526,6 +549,10 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
const newHireMonth = dateToMonth(newHireDate)
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
// 重新入职时支持更新工资结构
const baseSalaryNum = data.baseSalary != null ? Number(data.baseSalary) : (employee.baseSalary ? Number(decrypt(employee.baseSalary)) || 0 : 0)
const performanceSalaryNum = data.performanceSalary != null ? Number(data.performanceSalary) : (employee.performanceSalary ? Number(decrypt(employee.performanceSalary)) || 0 : 0)
const newMonthlySalary = data.baseSalary != null ? baseSalaryNum + performanceSalaryNum : salaryNum
const city = data.city || employee.city || '北京'
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
@@ -575,6 +602,12 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
housingFundStartMonth,
housingFundEndMonth: null,
city: data.city || employee.city || '北京',
// 更新工资结构
...(data.baseSalary != null ? {
monthlySalary: encrypt(String(newMonthlySalary)),
baseSalary: encrypt(String(baseSalaryNum)),
performanceSalary: encrypt(String(performanceSalaryNum)),
} : {}),
},
})
@@ -657,6 +690,8 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
contractYears: data.contract.contractYears || 3,
probationMonths: data.contract.probationMonths || 0,
probationSalary: data.contract.probationSalary || 0,
baseSalary: baseSalaryNum,
performanceSalary: performanceSalaryNum,
createdBy: userId,
},
})
@@ -702,6 +737,13 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
const oldSalary = Number(decrypt(employee.monthlySalary)) || 0
const newSalary = Number(data.monthlySalary) || 0
updateData.monthlySalary = encrypt(data.monthlySalary)
// 同步更新工资结构
if (data.baseSalary !== undefined) {
updateData.baseSalary = encrypt(String(Number(data.baseSalary) || 0))
}
if (data.performanceSalary !== undefined) {
updateData.performanceSalary = encrypt(String(Number(data.performanceSalary) || 0))
}
// 记录薪资变更
if (oldSalary !== newSalary) {
const now = new Date()
@@ -912,6 +954,8 @@ export async function batchRenew(orgId: string, userId: string, contractIds: str
contractYears: years,
probationMonths: 0,
probationSalary: 0,
baseSalary: contract.baseSalary || 0,
performanceSalary: contract.performanceSalary || 0,
renewalCount: contract.renewalCount + 1,
createdBy: userId,
},
@@ -963,6 +1007,8 @@ export async function addContract(orgId: string, userId: string, data: any) {
contractYears: data.contractYears || 3,
probationMonths: data.probationMonths || 0,
probationSalary: data.probationSalary || 0,
baseSalary: data.baseSalary || 0,
performanceSalary: data.performanceSalary || 0,
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
attachmentUrl: data.attachmentUrl || null,
electronicContractNo: data.electronicContractNo || null,
+2
View File
@@ -638,6 +638,7 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
where: { employeeId_month: { employeeId, month } },
update: {
baseSalary: Math.round(summary.baseSalary * 100) / 100,
performanceSalary: Math.round(summary.performanceSalary * 100) / 100,
overtimePay: Math.round(summary.overtimePay * 100) / 100,
allowance: Math.round(summary.allowance * 100) / 100,
deduction: Math.round(summary.deduction * 100) / 100,
@@ -659,6 +660,7 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
employeeId,
month,
baseSalary: Math.round(summary.baseSalary * 100) / 100,
performanceSalary: Math.round(summary.performanceSalary * 100) / 100,
overtimePay: Math.round(summary.overtimePay * 100) / 100,
allowance: Math.round(summary.allowance * 100) / 100,
deduction: Math.round(summary.deduction * 100) / 100,
+6 -6
View File
@@ -18,7 +18,7 @@ export const documentTemplates: DocumentTemplate[] = [
name: '固定期限劳动合同',
category: 'CONTRACT',
description: '标准固定期限劳动合同模板,适用于大多数正式员工',
variables: ['companyName', 'employeeName', 'idCard', 'address', 'phone', 'startDate', 'endDate', 'position', 'workplace', 'probationMonths', 'monthlySalary', 'socialInsBase'],
variables: ['companyName', 'employeeName', 'idCard', 'address', 'phone', 'startDate', 'endDate', 'position', 'workplace', 'probationMonths', 'baseSalary', 'performanceSalary', 'probationSalary', 'socialInsBase'],
content: `劳动合同书
甲方(用人单位):{{companyName}}
@@ -36,7 +36,7 @@ export const documentTemplates: DocumentTemplate[] = [
甲方执行标准工时制度,乙方每日工作时间不超过8小时,每周不超过40小时。
第四条 劳动报酬
乙方试用期月工资为人民币{{monthlySalary}}元,转正后月工资为人民币{{monthlySalary}}元甲方于每月15日前支付上月工资。
乙方基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月(绩效工资根据考核结果发放)。试用期月工资为人民币{{probationSalary}}元甲方于每月15日前支付上月工资。
第五条 社会保险和福利
甲方依法为乙方缴纳社会保险,缴费基数为{{socialInsBase}}元。
@@ -64,7 +64,7 @@ export const documentTemplates: DocumentTemplate[] = [
name: '无固定期限劳动合同',
category: 'CONTRACT',
description: '无固定期限劳动合同模板,适用于符合签订条件的情况',
variables: ['companyName', 'employeeName', 'startDate', 'position', 'monthlySalary'],
variables: ['companyName', 'employeeName', 'startDate', 'position', 'baseSalary', 'performanceSalary'],
content: `无固定期限劳动合同书
甲方(用人单位):{{companyName}}
@@ -79,7 +79,7 @@ export const documentTemplates: DocumentTemplate[] = [
乙方担任{{position}}岗位。
第三条 劳动报酬
乙方月工资为人民币{{monthlySalary}}元
乙方基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月(绩效工资根据考核结果发放)
(其余条款参照固定期限劳动合同模板)
@@ -241,7 +241,7 @@ ____年__月__日
name: '试用期转正通知书',
category: 'NOTICE',
description: '试用期考核合格转正通知',
variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'monthlySalary', 'companyName'],
variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'baseSalary', 'performanceSalary', 'companyName'],
content: `试用期转正通知书
{{employeeName}}
@@ -249,7 +249,7 @@ ____年__月__日
经考核,你在试用期内表现合格,符合岗位要求。现通知你:
一、自{{regularDate}}起正式转正,担任{{position}}岗位。
二、转正后月工资为人民币{{monthlySalary}}元。
二、转正后基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月
三、试用期至{{probationEndDate}}结束。
请继续遵守公司各项规章制度,努力工作。
+6 -2
View File
@@ -66,15 +66,19 @@ export async function executeWorkProcess(processId: string, type: string, formDa
return { employeeId }
}
case 'CONFIRM': {
const { employeeId, regularSalary } = formData
const { employeeId, regularSalary, baseSalary, performanceSalary } = formData
if (employeeId) {
if (regularSalary) {
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
const oldSalary = employee ? Number(decrypt(employee.monthlySalary)) || 0 : 0
const newSalary = Number(regularSalary) || 0
// 同步更新工资结构
const updateData: any = { monthlySalary: encrypt(String(regularSalary)) }
if (baseSalary !== undefined) updateData.baseSalary = encrypt(String(Number(baseSalary) || 0))
if (performanceSalary !== undefined) updateData.performanceSalary = encrypt(String(Number(performanceSalary) || 0))
await prisma.employee.update({
where: { id: employeeId },
data: { monthlySalary: encrypt(String(regularSalary)) },
data: updateData,
})
// 记录薪资变更(试用期薪资 → 转正薪资)
if (oldSalary !== newSalary) {