feat: 城市变更功能完善及跨页面缓存刷新修复
- 城市变更使用CITY_CHANGE类型替代ADJUST,月度办理显示减员/新增(城市变更) - 在保人员查询排除本月已关闭记录(gte→gt)和本月新增记录(lte→lt) - 社保/公积金减员查询包含CITY_CHANGE类型 - 缴纳记录表格添加城市列显示 - 后端profile API从快照提取city字段 - 修复旧快照缺失city字段的数据 - 修复旧记录changeType为CITY_CHANGE,endMonth与新记录startMonth一致 - 城市变更必填原因,写入备注和审计日志 - 员工详情页添加变更历史Tab - 移除薪酬社保Tab下重复的参保城市变更子Tab - 全局修复跨页面mutation缓存刷新:调薪/调部门/离职/重新入职/批量续签/批量解聘/社保公积金调基/月度办理/撤销解聘均刷新roster-profile
This commit is contained in:
@@ -158,6 +158,7 @@ model Organization {
|
||||
trainingRecords TrainingRecord[]
|
||||
performanceRecords PerformanceRecord[]
|
||||
retirementPolicies RetirementPolicy[]
|
||||
socialMonthlyProcesses SocialMonthlyProcess[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -739,6 +740,24 @@ model EmployeeHousingFundRecord {
|
||||
@@index([employeeId, startMonth, endMonth])
|
||||
}
|
||||
|
||||
/// 月度社保/公积金办理记录(标记某月已办理完成,保存快照)
|
||||
model SocialMonthlyProcess {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
month String // 办理月份 YYYY-MM
|
||||
type String // SOCIAL=社保, HOUSING=公积金
|
||||
status String @default("COMPLETED") // COMPLETED=已办理
|
||||
snapshot Json // 办理时的数据快照(增减员+在保人员+缴费明细)
|
||||
processedBy String
|
||||
processedAt DateTime @default(now())
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([orgId, month, type])
|
||||
@@index([orgId, month])
|
||||
}
|
||||
|
||||
model EmployeeDepartmentRecord {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import prisma from '../src/lib/prisma'
|
||||
|
||||
const EID = 'cmrx61v6d001oqqcwb2pu2tih'
|
||||
const ORGID = 'cmrx61v3l0000qqcwo3dr3h95'
|
||||
const UID = 'cmrx61v5u0002qqcwqf4vlyth'
|
||||
const EID = 'cmry97xbv001qtrrpuo12ozy7'
|
||||
const ORGID = 'cmry97x7l0000trrp5f9jgng5'
|
||||
const UID = 'cmry97xa10002trrp7peorkvh'
|
||||
|
||||
async function main() {
|
||||
// 加班记录
|
||||
// 加班记录(入职后按季度分布)
|
||||
const otMonths = [
|
||||
{ month: '2025-03', wh: 8, weh: 4, hh: 0, wp: 600, wep: 600, hp: 0, pay: 1200 },
|
||||
{ month: '2025-06', wh: 12, weh: 8, hh: 0, wp: 1200, wep: 1200, hp: 0, pay: 2400 },
|
||||
{ month: '2025-09', wh: 6, weh: 0, hh: 8, wp: 600, wep: 0, hp: 1200, pay: 1800 },
|
||||
{ month: '2025-12', wh: 10, weh: 4, hh: 0, wp: 900, wep: 600, hp: 0, pay: 1500 },
|
||||
{ month: '2026-03', wh: 8, weh: 8, hh: 0, wp: 600, wep: 1200, hp: 0, pay: 1800 },
|
||||
{ month: '2026-06', wh: 6, weh: 0, hh: 0, wp: 450, wep: 0, hp: 0, pay: 450 },
|
||||
]
|
||||
for (const o of otMonths) {
|
||||
const existing = await prisma.overtimeRecord.findUnique({ where: { employeeId_month: { employeeId: EID, month: o.month } } })
|
||||
@@ -19,10 +22,11 @@ async function main() {
|
||||
}
|
||||
console.log('加班记录: 完成')
|
||||
|
||||
// 违纪记录
|
||||
// 违纪记录(补充历史记录,保留已有的 2026-07-24 记录)
|
||||
const discRecords = [
|
||||
{ violationDate: new Date('2025-05-12'), violationType: 'LATE', description: '月度迟到超过5次,影响团队考勤', severity: 'WARNING', action: 'ORAL_WARNING', actionDetail: '口头警告并谈话', employeeAck: true, ackDate: new Date('2025-05-13'), ackMethod: 'SIGN', witness: '王强' },
|
||||
{ violationDate: new Date('2025-09-20'), violationType: 'ABSENT', description: '未经请假擅自旷工1天', severity: 'SERIOUS', action: 'DEDUCTION', actionDetail: '扣款200元', employeeAck: true, ackDate: new Date('2025-09-21'), ackMethod: 'SIGN', witness: '王强' },
|
||||
{ violationDate: new Date('2026-03-10'), violationType: 'INSUBORDINATION', description: '不服从主管工作安排,拒绝参加客户会议', severity: 'SERIOUS', action: 'WRITTEN_WARNING', actionDetail: '书面警告并记入档案', employeeAck: true, ackDate: new Date('2026-03-11'), ackMethod: 'SIGN', witness: '赵敏' },
|
||||
]
|
||||
for (const d of discRecords) {
|
||||
const existing = await prisma.disciplinaryRecord.findFirst({ where: { employeeId: EID, violationDate: d.violationDate } })
|
||||
@@ -32,23 +36,52 @@ async function main() {
|
||||
}
|
||||
console.log('违纪记录: 完成')
|
||||
|
||||
// 考勤记录 - 最近10个工作日
|
||||
// 考勤记录 - 2026年6-7月最近20个工作日
|
||||
const attendance = [
|
||||
{ date: '2026-07-10', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-07-11', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-07-14', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-07-15', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-07-16', status: 'LATE', late: 25, early: 0 },
|
||||
{ date: '2026-07-17', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-07-18', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-07-21', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-07-22', status: 'EARLY_LEAVE', late: 0, early: 30 },
|
||||
{ date: '2026-07-23', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-06-01', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-06-02', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-06-03', status: 'LATE', late: 15, early: 0, ot: 0 },
|
||||
{ date: '2026-06-04', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-06-05', status: 'NORMAL', late: 0, early: 0, ot: 2 },
|
||||
{ date: '2026-06-08', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-06-09', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-06-10', status: 'LEAVE', late: 0, early: 0, ot: 0, remark: '事假' },
|
||||
{ date: '2026-06-11', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-06-12', status: 'NORMAL', late: 0, early: 0, ot: 3 },
|
||||
{ date: '2026-06-15', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-06-16', status: 'LATE', late: 30, early: 0, ot: 0 },
|
||||
{ date: '2026-06-17', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-06-18', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-06-19', status: 'EARLY_LEAVE', late: 0, early: 45, ot: 0 },
|
||||
{ date: '2026-06-22', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-06-23', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-06-24', status: 'BUSINESS_TRIP', late: 0, early: 0, ot: 0, remark: '上海客户拜访' },
|
||||
{ date: '2026-06-25', status: 'BUSINESS_TRIP', late: 0, early: 0, ot: 0, remark: '上海客户拜访' },
|
||||
{ date: '2026-06-26', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-06-29', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-06-30', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-07-01', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-07-02', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-07-03', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-07-06', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-07-07', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-07-08', status: 'LATE', late: 20, early: 0, ot: 0 },
|
||||
{ date: '2026-07-09', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-07-10', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-07-13', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-07-14', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-07-15', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-07-16', status: 'LATE', late: 25, early: 0, ot: 0 },
|
||||
{ date: '2026-07-17', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-07-20', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-07-21', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
{ date: '2026-07-22', status: 'EARLY_LEAVE', late: 0, early: 30, ot: 0 },
|
||||
{ date: '2026-07-23', status: 'NORMAL', late: 0, early: 0, ot: 0 },
|
||||
]
|
||||
for (const a of attendance) {
|
||||
const existing = await prisma.attendanceRecord.findUnique({ where: { employeeId_date: { employeeId: EID, date: new Date(a.date) } } })
|
||||
if (!existing) {
|
||||
await prisma.attendanceRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, date: new Date(a.date), checkInTime: '09:00', checkOutTime: '18:00', status: a.status, lateMinutes: a.late, earlyMinutes: a.early, workHours: 8, overtimeHours: 0 } })
|
||||
await prisma.attendanceRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, date: new Date(a.date), checkInTime: a.status === 'LEAVE' ? null : '09:00', checkOutTime: a.status === 'LEAVE' ? null : '18:00', status: a.status, lateMinutes: a.late, earlyMinutes: a.early, workHours: a.status === 'LEAVE' ? 0 : 8, overtimeHours: a.ot || 0, remark: a.remark || null } })
|
||||
}
|
||||
}
|
||||
console.log('考勤记录: 完成')
|
||||
@@ -57,7 +90,9 @@ async function main() {
|
||||
const trainings = [
|
||||
{ trainingDate: new Date('2025-03-15'), topic: '《员工手册》培训', content: '公司规章制度、考勤制度、奖惩条例', trainer: '赵敏', duration: 2, ackStatus: 'SIGNED', ackDate: new Date('2025-03-15'), remark: '新员工入职培训' },
|
||||
{ trainingDate: new Date('2025-06-20'), topic: '销售技巧与合规培训', content: '销售话术规范、客户信息保护、合同签订注意事项', trainer: '王强', duration: 4, ackStatus: 'SIGNED', ackDate: new Date('2025-06-20') },
|
||||
{ trainingDate: new Date('2026-01-10'), topic: '2026年度规章制度更新培训', content: '新版考勤制度、绩效考核办法、安全生产规范', trainer: '赵敏', duration: 3, ackStatus: 'PENDING', remark: '待员工签收确认' },
|
||||
{ trainingDate: new Date('2025-09-10'), topic: '《数据安全管理制度》培训', content: '客户数据保护规范、信息安全操作规程、违规处罚条例', trainer: '赵敏', duration: 2, ackStatus: 'SIGNED', ackDate: new Date('2025-09-10') },
|
||||
{ trainingDate: new Date('2026-01-10'), topic: '2026年度规章制度更新培训', content: '新版考勤制度、绩效考核办法、安全生产规范', trainer: '赵敏', duration: 3, ackStatus: 'SIGNED', ackDate: new Date('2026-01-10') },
|
||||
{ trainingDate: new Date('2026-04-15'), topic: '销售合规与反商业贿赂培训', content: '反商业贿赂法规、客户招待标准、合规销售流程', trainer: '王强', duration: 3, ackStatus: 'PENDING', remark: '待员工签收确认' },
|
||||
]
|
||||
for (const t of trainings) {
|
||||
const existing = await prisma.trainingRecord.findFirst({ where: { employeeId: EID, trainingDate: t.trainingDate } })
|
||||
@@ -67,12 +102,14 @@ async function main() {
|
||||
}
|
||||
console.log('培训记录: 完成')
|
||||
|
||||
// 绩效记录
|
||||
// 绩效记录(从入职后按季度考核)
|
||||
const performances = [
|
||||
{ period: '2025-Q1', score: 82, grade: 'B', result: 'QUALIFIED', summary: '销售业绩达标,客户维护良好,需提升新客户开发能力', improvementPlan: '', employeeAck: true, ackDate: new Date('2025-04-10'), reviewer: '王强' },
|
||||
{ period: '2025-Q1', score: 82, grade: 'B', result: 'QUALIFIED', summary: '入职适应良好,销售业绩达标,客户维护良好,需提升新客户开发能力', improvementPlan: '', employeeAck: true, ackDate: new Date('2025-04-10'), reviewer: '王强' },
|
||||
{ period: '2025-Q2', score: 75, grade: 'B', result: 'QUALIFIED', summary: '业绩略有下滑,新客户开发不足,团队协作有待加强', improvementPlan: '', employeeAck: true, ackDate: new Date('2025-07-08'), reviewer: '王强' },
|
||||
{ period: '2025-Q3', score: 68, grade: 'C', result: 'NEED_IMPROVE', summary: '连续3个月未完成销售目标,客户投诉1次', improvementPlan: '调岗至客户维护岗,加强销售技巧培训1个月', employeeAck: true, ackDate: new Date('2025-10-15'), reviewer: '王强' },
|
||||
{ period: '2025-Q4', score: 78, grade: 'B', result: 'QUALIFIED', summary: '改进后业绩回升,客户满意度提升', improvementPlan: '', employeeAck: false, reviewer: '王强' },
|
||||
{ period: '2025-Q3', score: 68, grade: 'C', result: 'NEED_IMPROVE', summary: '连续3个月未完成销售目标,客户投诉1次,工作态度需改善', improvementPlan: '调岗至客户维护岗,加强销售技巧培训1个月', employeeAck: true, ackDate: new Date('2025-10-15'), reviewer: '王强' },
|
||||
{ period: '2025-Q4', score: 78, grade: 'B', result: 'QUALIFIED', summary: '改进后业绩回升,客户满意度提升,团队配合度改善', improvementPlan: '', employeeAck: true, ackDate: new Date('2026-01-12'), reviewer: '王强' },
|
||||
{ period: '2026-Q1', score: 72, grade: 'B', result: 'QUALIFIED', summary: '一季度业绩基本达标,大客户维护稳定,新签客户2家', improvementPlan: '', employeeAck: true, ackDate: new Date('2026-04-08'), reviewer: '王强' },
|
||||
{ period: '2026-Q2', score: 65, grade: 'C', result: 'NEED_IMPROVE', summary: '二季度业绩下滑明显,客户流失1家,不服从管理记录1次', improvementPlan: '加强客户维护培训,调整销售目标考核方式', employeeAck: false, reviewer: '王强' },
|
||||
]
|
||||
for (const p of performances) {
|
||||
const existing = await prisma.performanceRecord.findUnique({ where: { employeeId_period: { employeeId: EID, period: p.period } } })
|
||||
@@ -88,6 +125,8 @@ async function main() {
|
||||
{ fileName: '吴芳银行卡复印件.jpg', fileType: 'BANK_CARD', fileUrl: 'data:image/jpeg;base64,placeholder', fileSize: 51200 },
|
||||
{ fileName: '吴芳劳动合同扫描件.pdf', fileType: 'CONTRACT_SCAN', fileUrl: 'data:application/pdf;base64,placeholder', fileSize: 204800 },
|
||||
{ fileName: '吴芳学历证书.jpg', fileType: 'EDUCATION', fileUrl: 'data:image/jpeg;base64,placeholder', fileSize: 81920 },
|
||||
{ fileName: '吴芳学位证书.jpg', fileType: 'EDUCATION', fileUrl: 'data:image/jpeg;base64,placeholder', fileSize: 76800 },
|
||||
{ fileName: '吴芳离职证明(前单位).pdf', fileType: 'OTHER', fileUrl: 'data:application/pdf;base64,placeholder', fileSize: 153600 },
|
||||
]
|
||||
for (const a of attachments) {
|
||||
const existing = await prisma.employeeAttachment.findFirst({ where: { employeeId: EID, fileName: a.fileName } })
|
||||
@@ -97,10 +136,32 @@ async function main() {
|
||||
}
|
||||
console.log('附件: 完成')
|
||||
|
||||
// 社保缴费记录(入职)
|
||||
const hireMonth = '2025-02'
|
||||
const existingSocial = await prisma.employeeSocialInsRecord.findFirst({ where: { employeeId: EID, startMonth: hireMonth } })
|
||||
if (!existingSocial) {
|
||||
await prisma.employeeSocialInsRecord.create({
|
||||
data: { orgId: ORGID, employeeId: EID, startMonth: hireMonth, endMonth: null, base: 9000, changeType: 'ONBOARDING', createdBy: UID, city: '北京' },
|
||||
})
|
||||
}
|
||||
// 公积金缴费记录(入职)
|
||||
const existingHousing = await prisma.employeeHousingFundRecord.findFirst({ where: { employeeId: EID, startMonth: hireMonth } })
|
||||
if (!existingHousing) {
|
||||
await prisma.employeeHousingFundRecord.create({
|
||||
data: { orgId: ORGID, employeeId: EID, startMonth: hireMonth, endMonth: null, base: 9000, changeType: 'ONBOARDING', createdBy: UID, city: '北京' },
|
||||
})
|
||||
}
|
||||
// 更新 Employee 便捷字段
|
||||
await prisma.employee.update({
|
||||
where: { id: EID },
|
||||
data: { city: '北京', socialInsStartMonth: hireMonth, housingFundStartMonth: hireMonth },
|
||||
})
|
||||
console.log('社保/公积金: 完成')
|
||||
|
||||
// 验证
|
||||
const emp = await prisma.employee.findFirst({
|
||||
where: { id: EID },
|
||||
include: { contracts: true, payslips: true, overtimeRecords: true, disciplinaryRecords: true, attendanceRecords: true, trainingRecords: true, performanceRecords: true, terminations: true, attachments: true }
|
||||
include: { contracts: true, payslips: true, overtimeRecords: true, disciplinaryRecords: true, attendanceRecords: true, trainingRecords: true, performanceRecords: true, terminations: true, attachments: true, socialInsRecords: true, housingFundRecords: true }
|
||||
})
|
||||
if (emp) {
|
||||
console.log('--- 吴芳完整档案数据统计 ---')
|
||||
@@ -111,8 +172,10 @@ async function main() {
|
||||
console.log('attendanceRecords:', emp.attendanceRecords.length)
|
||||
console.log('trainingRecords:', emp.trainingRecords.length)
|
||||
console.log('performanceRecords:', emp.performanceRecords.length)
|
||||
console.log('terminations:', emp.terminations.length)
|
||||
console.log('terminations:', emp.terminations.length, JSON.stringify(emp.terminations.map(t => ({ status: t.status, type: t.type }))))
|
||||
console.log('attachments:', emp.attachments.length)
|
||||
console.log('socialInsRecords:', (emp as any).socialInsRecords?.length)
|
||||
console.log('housingFundRecords:', (emp as any).housingFundRecords?.length)
|
||||
}
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
|
||||
@@ -172,11 +172,46 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
|
||||
performanceRecords: { orderBy: { period: 'desc' } },
|
||||
terminations: { orderBy: { createdAt: 'desc' } },
|
||||
attachments: true,
|
||||
socialInsRecords: { orderBy: { startMonth: 'desc' } },
|
||||
housingFundRecords: { orderBy: { startMonth: 'desc' } },
|
||||
salaryChanges: { orderBy: { effectiveDate: 'desc' } },
|
||||
departmentRecords: { orderBy: { effectiveMonth: 'desc' } },
|
||||
},
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
|
||||
// 查询该员工相关的月度办理记录(从快照中筛选该员工)
|
||||
const allProcesses = await prisma.socialMonthlyProcess.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
orderBy: { month: 'desc' },
|
||||
})
|
||||
const employeeId = req.params.id
|
||||
const monthlyProcessRecords: any[] = []
|
||||
for (const p of allProcesses) {
|
||||
const snap = p.snapshot as any
|
||||
// 从增减员快照中筛选
|
||||
const changes = snap.changes
|
||||
const active = snap.active
|
||||
const type = p.type
|
||||
let found = false
|
||||
let recordData: any = { month: p.month, type, processedAt: p.processedAt, status: p.status }
|
||||
if (changes?.additions) {
|
||||
const item = changes.additions.find((a: any) => a.employeeId === employeeId)
|
||||
if (item) { recordData.changeType = '新增'; recordData.detail = item.detail; recordData.base = item.base; recordData.city = item.city; found = true }
|
||||
}
|
||||
if (!found && changes?.reductions) {
|
||||
const item = changes.reductions.find((a: any) => a.employeeId === employeeId)
|
||||
if (item) { recordData.changeType = '减少'; recordData.detail = item.detail; recordData.base = item.base; recordData.city = item.city; found = true }
|
||||
}
|
||||
if (!found && active?.items) {
|
||||
const item = active.items.find((a: any) => a.employeeId === employeeId)
|
||||
if (item) { recordData.changeType = '正常在保'; recordData.detail = item.detail; recordData.base = item.base; recordData.city = item.city; found = true }
|
||||
}
|
||||
if (found) monthlyProcessRecords.push(recordData)
|
||||
}
|
||||
|
||||
const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
@@ -189,6 +224,7 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
|
||||
monthlySalary: safeDecrypt(monthlySalary),
|
||||
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
|
||||
idCardNumber: idCardNumber ? safeDecrypt(idCardNumber).toString() : null,
|
||||
monthlyProcessRecords,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -787,6 +787,57 @@ router.post('/housing-config/:id/reset-adjustment', async (req: AuthRequest, res
|
||||
|
||||
// ========== 月度增减员 ==========
|
||||
|
||||
/** 根据基数和社保配置计算各项企业/个人缴费明细 */
|
||||
function calcSocialDetail(base: number, config: any) {
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
const items = [
|
||||
{ name: '养老', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: actualBase * config.pensionOrg / 100, empAmount: actualBase * config.pensionEmp / 100 },
|
||||
{ name: '医疗', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: actualBase * config.medicalOrg / 100, empAmount: actualBase * config.medicalEmp / 100 },
|
||||
{ name: '失业', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: actualBase * config.unemploymentOrg / 100, empAmount: actualBase * config.unemploymentEmp / 100 },
|
||||
{ name: '工伤', orgRate: config.injuryOrg, empRate: 0, orgAmount: actualBase * config.injuryOrg / 100, empAmount: 0 },
|
||||
{ name: '生育', orgRate: config.maternityOrg, empRate: 0, orgAmount: actualBase * config.maternityOrg / 100, empAmount: 0 },
|
||||
]
|
||||
const totalOrg = items.reduce((s, i) => s + i.orgAmount, 0)
|
||||
const totalEmp = items.reduce((s, i) => s + i.empAmount, 0)
|
||||
return { actualBase, items, totalOrg, totalEmp }
|
||||
}
|
||||
|
||||
/** 根据基数和公积金配置计算企业/个人缴费明细 */
|
||||
function calcHousingDetail(base: number, config: any) {
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
const orgAmount = actualBase * config.housingOrg / 100
|
||||
const empAmount = actualBase * config.housingEmp / 100
|
||||
return { actualBase, orgAmount, empAmount, total: orgAmount + empAmount }
|
||||
}
|
||||
|
||||
/** 按月份匹配社保配置版本 */
|
||||
async function getSocialConfigByMonth(orgId: string, month: string, city?: string) {
|
||||
const where: any = { orgId }
|
||||
if (city) where.city = city
|
||||
let config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { ...where, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.findFirst({ where: { ...where, isCurrent: true } })
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
/** 按月份匹配公积金配置版本 */
|
||||
async function getHousingConfigByMonth(orgId: string, month: string, city?: string) {
|
||||
const where: any = { orgId }
|
||||
if (city) where.city = city
|
||||
let config = await prisma.housingFundConfig.findFirst({
|
||||
where: { ...where, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (!config) {
|
||||
config = await prisma.housingFundConfig.findFirst({ where: { ...where, isCurrent: true } })
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// 社保月度增减员
|
||||
router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@@ -800,33 +851,59 @@ router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: Nex
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
// 减员:endMonth == month 且 changeType == TERMINATION
|
||||
// 减员:endMonth == month 且 changeType 为 TERMINATION 或 CITY_CHANGE
|
||||
const reductions = await prisma.employeeSocialInsRecord.findMany({
|
||||
where: { orgId, endMonth: month, changeType: 'TERMINATION' },
|
||||
where: { orgId, endMonth: month, changeType: { in: ['TERMINATION', 'CITY_CHANGE'] } },
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
// 按城市缓存配置
|
||||
const configCache = new Map<string, any>()
|
||||
const getConfigForCity = async (city: string) => {
|
||||
if (!configCache.has(city)) {
|
||||
configCache.set(city, await getSocialConfigByMonth(orgId, month, city))
|
||||
}
|
||||
return configCache.get(city)
|
||||
}
|
||||
|
||||
const mapRecord = async (r: any) => {
|
||||
const config = await getConfigForCity(r.city)
|
||||
const detail = config ? calcSocialDetail(r.base, config) : null
|
||||
return {
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
city: r.city,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
detail: detail ? {
|
||||
items: detail.items,
|
||||
totalOrg: detail.totalOrg,
|
||||
totalEmp: detail.totalEmp,
|
||||
total: detail.totalOrg + detail.totalEmp,
|
||||
} : null,
|
||||
}
|
||||
}
|
||||
|
||||
// 按城市分组
|
||||
const allRecords = [...additions, ...reductions]
|
||||
const cities = [...new Set(allRecords.map((r) => r.city))]
|
||||
const configs: Record<string, any> = {}
|
||||
for (const c of cities) {
|
||||
const cfg = await getConfigForCity(c)
|
||||
if (cfg) configs[c] = { city: cfg.city, effectiveFrom: cfg.effectiveFrom, baseMin: cfg.baseMin, baseMax: cfg.baseMax }
|
||||
}
|
||||
|
||||
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,
|
||||
})),
|
||||
configs,
|
||||
additions: await Promise.all(additions.map(mapRecord)),
|
||||
reductions: await Promise.all(reductions.map(mapRecord)),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -847,31 +924,50 @@ router.get('/housing/monthly-changes', async (req: AuthRequest, res: Response, n
|
||||
})
|
||||
|
||||
const reductions = await prisma.employeeHousingFundRecord.findMany({
|
||||
where: { orgId, endMonth: month, changeType: 'TERMINATION' },
|
||||
where: { orgId, endMonth: month, changeType: { in: ['TERMINATION', 'CITY_CHANGE'] } },
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
const configCache = new Map<string, any>()
|
||||
const getConfigForCity = async (city: string) => {
|
||||
if (!configCache.has(city)) {
|
||||
configCache.set(city, await getHousingConfigByMonth(orgId, month, city))
|
||||
}
|
||||
return configCache.get(city)
|
||||
}
|
||||
|
||||
const mapRecord = async (r: any) => {
|
||||
const config = await getConfigForCity(r.city)
|
||||
const detail = config ? calcHousingDetail(r.base, config) : null
|
||||
return {
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
city: r.city,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
detail: detail ? { orgAmount: detail.orgAmount, empAmount: detail.empAmount, total: detail.total } : null,
|
||||
}
|
||||
}
|
||||
|
||||
const allRecords = [...additions, ...reductions]
|
||||
const cities = [...new Set(allRecords.map((r) => r.city))]
|
||||
const configs: Record<string, any> = {}
|
||||
for (const c of cities) {
|
||||
const cfg = await getConfigForCity(c)
|
||||
if (cfg) configs[c] = { city: cfg.city, effectiveFrom: cfg.effectiveFrom, baseMin: cfg.baseMin, baseMax: cfg.baseMax, housingOrg: cfg.housingOrg, housingEmp: cfg.housingEmp }
|
||||
}
|
||||
|
||||
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,
|
||||
})),
|
||||
configs,
|
||||
additions: await Promise.all(additions.map(mapRecord)),
|
||||
reductions: await Promise.all(reductions.map(mapRecord)),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -890,27 +986,52 @@ router.get('/active-declaration', async (req: AuthRequest, res: Response, next:
|
||||
const records = await prisma.employeeSocialInsRecord.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
startMonth: { lte: month },
|
||||
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
|
||||
startMonth: { lt: month },
|
||||
OR: [{ endMonth: null }, { endMonth: { gt: month } }],
|
||||
},
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
const configCache = new Map<string, any>()
|
||||
const getConfigForCity = async (city: string) => {
|
||||
if (!configCache.has(city)) {
|
||||
configCache.set(city, await getSocialConfigByMonth(orgId, month, city))
|
||||
}
|
||||
return configCache.get(city)
|
||||
}
|
||||
|
||||
const items = await Promise.all(records.map(async (r) => {
|
||||
const config = await getConfigForCity(r.city)
|
||||
const detail = config ? calcSocialDetail(r.base, config) : null
|
||||
return {
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
city: r.city,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
detail: detail ? {
|
||||
items: detail.items,
|
||||
totalOrg: detail.totalOrg,
|
||||
totalEmp: detail.totalEmp,
|
||||
total: detail.totalOrg + detail.totalEmp,
|
||||
} : null,
|
||||
}
|
||||
}))
|
||||
|
||||
const cities = [...new Set(records.map((r) => r.city))]
|
||||
const configs: Record<string, any> = {}
|
||||
for (const c of cities) {
|
||||
const cfg = await getConfigForCity(c)
|
||||
if (cfg) configs[c] = { city: cfg.city, effectiveFrom: cfg.effectiveFrom, baseMin: cfg.baseMin, baseMax: cfg.baseMax }
|
||||
}
|
||||
|
||||
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,
|
||||
})),
|
||||
},
|
||||
data: { month, configs, items },
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
@@ -926,26 +1047,85 @@ router.get('/housing/active-declaration', async (req: AuthRequest, res: Response
|
||||
const records = await prisma.employeeHousingFundRecord.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
startMonth: { lte: month },
|
||||
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
|
||||
startMonth: { lt: month },
|
||||
OR: [{ endMonth: null }, { endMonth: { gt: month } }],
|
||||
},
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
const configCache = new Map<string, any>()
|
||||
const getConfigForCity = async (city: string) => {
|
||||
if (!configCache.has(city)) {
|
||||
configCache.set(city, await getHousingConfigByMonth(orgId, month, city))
|
||||
}
|
||||
return configCache.get(city)
|
||||
}
|
||||
|
||||
const items = await Promise.all(records.map(async (r) => {
|
||||
const config = await getConfigForCity(r.city)
|
||||
const detail = config ? calcHousingDetail(r.base, config) : null
|
||||
return {
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
city: r.city,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
detail: detail ? { orgAmount: detail.orgAmount, empAmount: detail.empAmount, total: detail.total } : null,
|
||||
}
|
||||
}))
|
||||
|
||||
const cities = [...new Set(records.map((r) => r.city))]
|
||||
const configs: Record<string, any> = {}
|
||||
for (const c of cities) {
|
||||
const cfg = await getConfigForCity(c)
|
||||
if (cfg) configs[c] = { city: cfg.city, effectiveFrom: cfg.effectiveFrom, baseMin: cfg.baseMin, baseMax: cfg.baseMax, housingOrg: cfg.housingOrg, housingEmp: cfg.housingEmp }
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { month, configs, items },
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 月度办理完成(保存快照) ==========
|
||||
|
||||
// 列出所有已办理月份(用于办理总览)
|
||||
router.get('/monthly-process/list', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const records = await prisma.socialMonthlyProcess.findMany({
|
||||
where: { orgId },
|
||||
orderBy: { month: 'desc' },
|
||||
select: { id: true, month: true, type: true, status: true, processedAt: true, processedBy: true },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 查询某月办理状态
|
||||
router.get('/monthly-process/status', 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.socialMonthlyProcess.findMany({
|
||||
where: { orgId, month },
|
||||
})
|
||||
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,
|
||||
})),
|
||||
social: records.find((r) => r.type === 'SOCIAL') || null,
|
||||
housing: records.find((r) => r.type === 'HOUSING') || null,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -953,4 +1133,145 @@ router.get('/housing/active-declaration', async (req: AuthRequest, res: Response
|
||||
}
|
||||
})
|
||||
|
||||
// 办理完成(保存快照)
|
||||
router.post('/monthly-process/complete', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, type, snapshot } = req.body as { month: string; type: 'SOCIAL' | 'HOUSING'; snapshot: any }
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
if (!month || !type || !snapshot) {
|
||||
return res.status(400).json({ success: false, message: '缺少必要参数' })
|
||||
}
|
||||
|
||||
const existing = await prisma.socialMonthlyProcess.findUnique({
|
||||
where: { orgId_month_type: { orgId, month, type } },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
// 已存在则更新快照
|
||||
const updated = await prisma.socialMonthlyProcess.update({
|
||||
where: { id: existing.id },
|
||||
data: { snapshot, processedBy: req.user!.id, processedAt: new Date() },
|
||||
})
|
||||
return res.json({ success: true, data: updated })
|
||||
}
|
||||
|
||||
const record = await prisma.socialMonthlyProcess.create({
|
||||
data: {
|
||||
orgId,
|
||||
month,
|
||||
type,
|
||||
snapshot,
|
||||
processedBy: req.user!.id,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 记录修正(直接更新 + 审计日志) ==========
|
||||
|
||||
// 修正社保记录
|
||||
router.put('/records/social/:id/correct', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { city, base, startMonth, endMonth, changeType, remark } = req.body as { city?: string; base?: number; startMonth?: string; endMonth?: string; changeType?: string; remark?: string }
|
||||
|
||||
const record = await prisma.employeeSocialInsRecord.findFirst({ where: { id: req.params.id, orgId } })
|
||||
if (!record) return res.status(404).json({ success: false, message: '记录不存在' })
|
||||
|
||||
const oldData = { city: record.city, base: record.base, startMonth: record.startMonth, endMonth: record.endMonth, changeType: record.changeType, remark: record.remark }
|
||||
const updateData: any = {}
|
||||
if (city !== undefined) updateData.city = city
|
||||
if (base !== undefined) updateData.base = base
|
||||
if (startMonth !== undefined) updateData.startMonth = startMonth
|
||||
if (endMonth !== undefined) updateData.endMonth = endMonth || null
|
||||
if (changeType !== undefined) updateData.changeType = changeType
|
||||
if (remark !== undefined) updateData.remark = remark
|
||||
|
||||
const updated = await prisma.employeeSocialInsRecord.update({ where: { id: req.params.id }, data: updateData })
|
||||
|
||||
// 同步员工便捷字段(如果修正的是当前在保记录)
|
||||
if (!updated.endMonth) {
|
||||
await prisma.employee.update({
|
||||
where: { id: record.employeeId },
|
||||
data: {
|
||||
...(city !== undefined ? { city } : {}),
|
||||
...(base !== undefined ? { socialInsBase: base } : {}),
|
||||
...(startMonth !== undefined ? { socialInsStartMonth: startMonth } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 写审计日志
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orgId,
|
||||
userId: req.user!.id,
|
||||
action: 'CORRECT',
|
||||
entity: 'EmployeeSocialInsRecord',
|
||||
entityId: req.params.id,
|
||||
detail: { old: oldData, new: updateData, reason: req.body.reason || '数据修正' },
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 修正公积金记录
|
||||
router.put('/records/housing/:id/correct', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { city, base, startMonth, endMonth, changeType, remark } = req.body as { city?: string; base?: number; startMonth?: string; endMonth?: string; changeType?: string; remark?: string }
|
||||
|
||||
const record = await prisma.employeeHousingFundRecord.findFirst({ where: { id: req.params.id, orgId } })
|
||||
if (!record) return res.status(404).json({ success: false, message: '记录不存在' })
|
||||
|
||||
const oldData = { city: record.city, base: record.base, startMonth: record.startMonth, endMonth: record.endMonth, changeType: record.changeType, remark: record.remark }
|
||||
const updateData: any = {}
|
||||
if (city !== undefined) updateData.city = city
|
||||
if (base !== undefined) updateData.base = base
|
||||
if (startMonth !== undefined) updateData.startMonth = startMonth
|
||||
if (endMonth !== undefined) updateData.endMonth = endMonth || null
|
||||
if (changeType !== undefined) updateData.changeType = changeType
|
||||
if (remark !== undefined) updateData.remark = remark
|
||||
|
||||
const updated = await prisma.employeeHousingFundRecord.update({ where: { id: req.params.id }, data: updateData })
|
||||
|
||||
// 同步员工便捷字段(如果修正的是当前在保记录)
|
||||
if (!updated.endMonth) {
|
||||
await prisma.employee.update({
|
||||
where: { id: record.employeeId },
|
||||
data: {
|
||||
...(city !== undefined ? { city } : {}),
|
||||
...(base !== undefined ? { housingFundBase: base } : {}),
|
||||
...(startMonth !== undefined ? { housingFundStartMonth: startMonth } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 写审计日志
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orgId,
|
||||
userId: req.user!.id,
|
||||
action: 'CORRECT',
|
||||
entity: 'EmployeeHousingFundRecord',
|
||||
entityId: req.params.id,
|
||||
detail: { old: oldData, new: updateData, reason: req.body.reason || '数据修正' },
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -521,6 +521,100 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction
|
||||
if (data.city !== undefined) updateData.city = data.city
|
||||
|
||||
// 参保城市变更:关闭旧城市在保记录,创建新城市记录
|
||||
if (data.city !== undefined && data.city !== employee.city) {
|
||||
const nowMonth = new Date().toISOString().slice(0, 7)
|
||||
const cityChangeReason = data.cityChangeReason || '未填写原因'
|
||||
const changeRemark = `城市变更:${employee.city || '未设置'} → ${data.city}(${cityChangeReason})`
|
||||
// 社保:关闭旧在保记录,创建新城市记录
|
||||
const activeSocial = await prisma.employeeSocialInsRecord.findFirst({
|
||||
where: { employeeId: id, endMonth: null },
|
||||
})
|
||||
if (activeSocial) {
|
||||
await prisma.employeeSocialInsRecord.update({
|
||||
where: { id: activeSocial.id },
|
||||
data: { endMonth: nowMonth, changeType: 'CITY_CHANGE', remark: changeRemark },
|
||||
})
|
||||
await prisma.employeeSocialInsRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
city: data.city,
|
||||
startMonth: nowMonth,
|
||||
endMonth: null,
|
||||
base: activeSocial.base,
|
||||
changeType: 'CITY_CHANGE',
|
||||
remark: changeRemark,
|
||||
createdBy: '',
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// 兜底:没有在保记录也创建一条,保留变更历史
|
||||
await prisma.employeeSocialInsRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
city: data.city,
|
||||
startMonth: nowMonth,
|
||||
endMonth: null,
|
||||
base: employee.socialInsBase || 0,
|
||||
changeType: 'CITY_CHANGE',
|
||||
remark: changeRemark,
|
||||
createdBy: '',
|
||||
},
|
||||
})
|
||||
}
|
||||
// 公积金:同上
|
||||
const activeHousing = await prisma.employeeHousingFundRecord.findFirst({
|
||||
where: { employeeId: id, endMonth: null },
|
||||
})
|
||||
if (activeHousing) {
|
||||
await prisma.employeeHousingFundRecord.update({
|
||||
where: { id: activeHousing.id },
|
||||
data: { endMonth: nowMonth, changeType: 'CITY_CHANGE', remark: changeRemark },
|
||||
})
|
||||
await prisma.employeeHousingFundRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
city: data.city,
|
||||
startMonth: nowMonth,
|
||||
endMonth: null,
|
||||
base: activeHousing.base,
|
||||
changeType: 'CITY_CHANGE',
|
||||
remark: changeRemark,
|
||||
createdBy: '',
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// 兜底:没有在保记录也创建一条
|
||||
await prisma.employeeHousingFundRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
city: data.city,
|
||||
startMonth: nowMonth,
|
||||
endMonth: null,
|
||||
base: employee.housingFundBase || 0,
|
||||
changeType: 'CITY_CHANGE',
|
||||
remark: changeRemark,
|
||||
createdBy: '',
|
||||
},
|
||||
})
|
||||
}
|
||||
// 写审计日志
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orgId,
|
||||
userId: '',
|
||||
action: 'CITY_CHANGE',
|
||||
entity: 'Employee',
|
||||
entityId: id,
|
||||
detail: { oldCity: employee.city, newCity: data.city, reason: cityChangeReason, remark: changeRemark },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.employee.update({ where: { id }, data: updateData })
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export default function Contracts() {
|
||||
queryClient.invalidateQueries({ queryKey: ['employees'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
setShowAddModal(false)
|
||||
},
|
||||
})
|
||||
|
||||
+1012
-159
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download } from 'lucide-react'
|
||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -14,7 +14,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
|
||||
export default function SocialInsurance() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [tab, setTab] = useState<'social' | 'housing' | 'monthly'>('social')
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing'>('monthly')
|
||||
const [city, setCity] = useState<string>('北京')
|
||||
const [base, setBase] = useState(8000)
|
||||
const [showNewVersion, setShowNewVersion] = useState(false)
|
||||
@@ -24,6 +24,8 @@ export default function SocialInsurance() {
|
||||
const [editItems, setEditItems] = useState<Record<string, number>>({})
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [monthlyProcessed, setMonthlyProcessed] = useState(false)
|
||||
const [processStatus, setProcessStatus] = useState<{ social: any; housing: any } | null>(null)
|
||||
const [newVersion, setNewVersion] = useState<any>({
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
city: '北京',
|
||||
@@ -83,9 +85,28 @@ export default function SocialInsurance() {
|
||||
enabled: showVersions && tab === 'housing',
|
||||
})
|
||||
|
||||
const { data: monthlyChanges } = useQuery<any>({
|
||||
queryKey: ['monthly-changes', monthlyMonth],
|
||||
// 已办理月份列表(进入月度办理Tab时自动加载)
|
||||
const { data: processedList, refetch: refetchProcessedList } = useQuery<any[]>({
|
||||
queryKey: ['monthly-process-list'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/monthly-process/list') as any
|
||||
return res.data
|
||||
},
|
||||
enabled: tab === 'monthly',
|
||||
})
|
||||
|
||||
// 进入月度办理Tab时自动查询当前月状态
|
||||
useEffect(() => {
|
||||
if (tab === 'monthly') {
|
||||
api.get('/social/monthly-process/status', { params: { month: monthlyMonth } }).then((res: any) => {
|
||||
setProcessStatus(res.data)
|
||||
}).catch(() => {})
|
||||
refetchProcessedList()
|
||||
}
|
||||
}, [tab])
|
||||
|
||||
const { mutateAsync: fetchMonthlyChanges, isPending: monthlyLoading, data: monthlyChanges } = useMutation<any>({
|
||||
mutationFn: async () => {
|
||||
const [socialRes, housingRes, socialActiveRes, housingActiveRes] = await Promise.all([
|
||||
api.get('/social/monthly-changes', { params: { month: monthlyMonth } }) as any,
|
||||
api.get('/social/housing/monthly-changes', { params: { month: monthlyMonth } }) as any,
|
||||
@@ -99,7 +120,40 @@ export default function SocialInsurance() {
|
||||
housingActive: housingActiveRes.data,
|
||||
}
|
||||
},
|
||||
enabled: tab === 'monthly',
|
||||
})
|
||||
|
||||
const handleMonthlyProcess = async () => {
|
||||
try {
|
||||
await fetchMonthlyChanges()
|
||||
setMonthlyProcessed(true)
|
||||
// 查询该月办理状态
|
||||
const statusRes = await api.get('/social/monthly-process/status', { params: { month: monthlyMonth } }) as any
|
||||
setProcessStatus(statusRes.data)
|
||||
} catch {
|
||||
toast.error('获取月度办理数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
const completeProcessMutation = useMutation({
|
||||
mutationFn: async (type: 'SOCIAL' | 'HOUSING') => {
|
||||
const snapshot = type === 'SOCIAL' ? monthlyChanges.social : monthlyChanges.housing
|
||||
const activeSnapshot = type === 'SOCIAL' ? monthlyChanges.socialActive : monthlyChanges.housingActive
|
||||
const res = await api.post('/social/monthly-process/complete', {
|
||||
month: monthlyMonth,
|
||||
type,
|
||||
snapshot: { changes: snapshot, active: activeSnapshot },
|
||||
}) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (data: any, type: 'SOCIAL' | 'HOUSING') => {
|
||||
setProcessStatus((prev: any) => ({ ...prev, [type === 'SOCIAL' ? 'social' : 'housing']: data }))
|
||||
refetchProcessedList()
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
toast.success(`${type === 'SOCIAL' ? '社保' : '公积金'}月度办理已完成并保存`)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('保存办理记录失败')
|
||||
},
|
||||
})
|
||||
|
||||
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
|
||||
@@ -164,6 +218,8 @@ export default function SocialInsurance() {
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
setShowAdjust(false)
|
||||
setAdjustData(null)
|
||||
setEditItems({})
|
||||
@@ -178,6 +234,8 @@ export default function SocialInsurance() {
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
setShowAdjust(false)
|
||||
setAdjustData(null)
|
||||
setEditItems({})
|
||||
@@ -207,11 +265,15 @@ export default function SocialInsurance() {
|
||||
const handleExportCSV = (type: 'social' | 'housing', data: any) => {
|
||||
if (!data?.items?.length) return
|
||||
const headers = type === 'social'
|
||||
? ['姓名', '部门', '社保基数', '开始年月', '截止年月', '变更类型']
|
||||
: ['姓名', '部门', '公积金基数', '开始年月', '截止年月', '变更类型']
|
||||
const rows = data.items.map((i: any) => [
|
||||
i.name, i.department, i.base, i.startMonth, i.endMonth || '', i.changeType
|
||||
])
|
||||
? ['姓名', '部门', '社保基数', '企业部分', '个人部分', '合计', '开始年月', '截止年月', '变更类型']
|
||||
: ['姓名', '部门', '公积金基数', '企业部分', '个人部分', '合计', '开始年月', '截止年月', '变更类型']
|
||||
const rows = data.items.map((i: any) => {
|
||||
const d = i.detail
|
||||
if (type === 'social') {
|
||||
return [i.name, i.department, i.base, d?.totalOrg || '', d?.totalEmp || '', d?.total || '', i.startMonth, i.endMonth || '', i.changeType]
|
||||
}
|
||||
return [i.name, i.department, i.base, d?.orgAmount || '', d?.empAmount || '', d?.total || '', i.startMonth, i.endMonth || '', i.changeType]
|
||||
})
|
||||
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -258,31 +320,33 @@ export default function SocialInsurance() {
|
||||
|
||||
{/* Tab 切换 + 城市选择 */}
|
||||
<div className="flex items-center gap-4 border-b">
|
||||
{(['social', 'housing', 'monthly'] as const).map((t) => (
|
||||
{(['monthly', 'social', 'housing'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}
|
||||
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null); setMonthlyProcessed(false); setProcessStatus(null) }}
|
||||
>
|
||||
{t === 'social' ? '社保' : t === 'housing' ? '公积金' : '月度办理'}
|
||||
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : '公积金'}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<label className="text-sm text-gray-500">城市:</label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
>
|
||||
{cities.length > 0 ? (
|
||||
cities.map((c) => <option key={c} value={c}>{c}</option>)
|
||||
) : (
|
||||
<option value="北京">北京</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
{tab !== 'monthly' && (
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<label className="text-sm text-gray-500">城市:</label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
>
|
||||
{cities.length > 0 ? (
|
||||
cities.map((c) => <option key={c} value={c}>{c}</option>)
|
||||
) : (
|
||||
<option value="北京">北京</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ========== 社保 / 公积金 Tab ========== */}
|
||||
@@ -622,131 +686,218 @@ export default function SocialInsurance() {
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium">月度办理</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input type="month" value={monthlyMonth} onChange={(e) => setMonthlyMonth(e.target.value)} className="!w-32" />
|
||||
<Button variant="secondary" size="sm" onClick={() => monthlyChanges && handleExportCSV('social', monthlyChanges.social)}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" />导出社保
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => monthlyChanges && handleExportCSV('housing', monthlyChanges.housing)}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" />导出公积金
|
||||
<Input type="month" value={monthlyMonth} onChange={(e) => { setMonthlyMonth(e.target.value); setMonthlyProcessed(false); setProcessStatus(null) }} className="!w-32" />
|
||||
<Button size="sm" onClick={handleMonthlyProcess} disabled={monthlyLoading}>
|
||||
{monthlyLoading ? '获取中...' : '获取'}
|
||||
</Button>
|
||||
{monthlyProcessed && monthlyChanges && (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => handleExportCSV('social', monthlyChanges.social)}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" />导出社保
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => handleExportCSV('housing', monthlyChanges.housing)}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" />导出公积金
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 办理状态总览:近12个月时间线 */}
|
||||
{processedList && (() => {
|
||||
const now = new Date()
|
||||
const months: string[] = []
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
|
||||
}
|
||||
const socialMonths = new Set(processedList.filter((r: any) => r.type === 'SOCIAL').map((r: any) => r.month))
|
||||
const housingMonths = new Set(processedList.filter((r: any) => r.type === 'HOUSING').map((r: any) => r.month))
|
||||
const currentMonth = monthlyMonth
|
||||
return (
|
||||
<div className="mb-3 p-3 bg-gray-50 rounded-md">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-3.5 h-3.5 text-gray-400" />
|
||||
<span className="text-xs font-medium text-gray-600">办理状态总览(近6个月)</span>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{months.map((m) => {
|
||||
const sDone = socialMonths.has(m)
|
||||
const hDone = housingMonths.has(m)
|
||||
const isCurrent = m === currentMonth
|
||||
const allDone = sDone && hDone
|
||||
const partial = (sDone || hDone) && !allDone
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => { setMonthlyMonth(m); setMonthlyProcessed(false); setProcessStatus(null) }}
|
||||
className={`px-3 py-1.5 rounded-md text-xs border transition-all ${isCurrent ? 'ring-2 ring-primary/20 border-primary' : 'border-gray-200'} ${allDone ? 'bg-green-50' : partial ? 'bg-amber-50' : 'bg-white hover:bg-gray-100'}`}
|
||||
>
|
||||
<div className="font-medium">{m}</div>
|
||||
<div className="flex gap-1 mt-0.5">
|
||||
<span className={`px-1 rounded text-[10px] ${sDone ? 'bg-green-100 text-safe' : 'bg-gray-100 text-gray-400'}`}>社保{sDone ? '✓' : '×'}</span>
|
||||
<span className={`px-1 rounded text-[10px] ${hDone ? 'bg-green-100 text-safe' : 'bg-gray-100 text-gray-400'}`}>公积金{hDone ? '✓' : '×'}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{(() => {
|
||||
const sDone = socialMonths.has(currentMonth)
|
||||
const hDone = housingMonths.has(currentMonth)
|
||||
if (sDone && hDone) return <div className="mt-2 text-xs text-safe flex items-center gap-1"><Check className="w-3.5 h-3.5" />{currentMonth} 社保和公积金均已办理完成</div>
|
||||
if (sDone || hDone) return <div className="mt-2 text-xs text-amber-600 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" />{currentMonth} {sDone ? '公积金' : '社保'}尚未办理完成</div>
|
||||
return <div className="mt-2 text-xs text-gray-500 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" />{currentMonth} 社保和公积金均未办理</div>
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{/* 办理完成按钮区 */}
|
||||
{monthlyProcessed && monthlyChanges && (
|
||||
<div className="flex items-center gap-3 mb-3 pb-3 border-b">
|
||||
<Button size="sm" onClick={() => completeProcessMutation.mutate('SOCIAL')} disabled={completeProcessMutation.isPending}>
|
||||
{processStatus?.social ? '重新办理完成(社保)' : '办理完成(社保)'}
|
||||
</Button>
|
||||
{processStatus?.social && (
|
||||
<span className="text-xs text-safe flex items-center gap-1">
|
||||
<Check className="w-3.5 h-3.5" />社保已办理 {new Date(processStatus.social.processedAt).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
<Button size="sm" onClick={() => completeProcessMutation.mutate('HOUSING')} disabled={completeProcessMutation.isPending}>
|
||||
{processStatus?.housing ? '重新办理完成(公积金)' : '办理完成(公积金)'}
|
||||
</Button>
|
||||
{processStatus?.housing && (
|
||||
<span className="text-xs text-safe flex items-center gap-1">
|
||||
<Check className="w-3.5 h-3.5" />公积金已办理 {new Date(processStatus.housing.processedAt).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-blue-50 text-blue-700 text-sm px-3 py-2 rounded-md mb-3">
|
||||
展示当月社保/公积金新增(入职/重新入职)、减少(离职/解聘)及正常在保人员列表,用于经办机构申报。
|
||||
</div>
|
||||
{(() => {
|
||||
if (!monthlyChanges) return <div className="text-center py-4 text-gray-400 text-sm">加载中...</div>
|
||||
if (!monthlyProcessed) {
|
||||
return <div className="text-center py-8 text-gray-400 text-sm">选择月份后点击「获取」按钮,获取当月增减员及在保人员列表</div>
|
||||
}
|
||||
if (monthlyLoading) return <div className="text-center py-4 text-gray-400 text-sm">加载中...</div>
|
||||
if (!monthlyChanges) return <div className="text-center py-4 text-gray-400 text-sm">无数据</div>
|
||||
const sAdd = monthlyChanges.social?.additions || []
|
||||
const sSub = monthlyChanges.social?.subtractions || []
|
||||
const sSub = monthlyChanges.social?.reductions || []
|
||||
const sNormal = monthlyChanges.socialActive?.items || []
|
||||
const hAdd = monthlyChanges.housing?.additions || []
|
||||
const hSub = monthlyChanges.housing?.subtractions || []
|
||||
const hSub = monthlyChanges.housing?.reductions || []
|
||||
const hNormal = monthlyChanges.housingActive?.items || []
|
||||
if (sAdd.length === 0 && sSub.length === 0 && hAdd.length === 0 && hSub.length === 0 && sNormal.length === 0 && hNormal.length === 0) {
|
||||
return <div className="text-center py-4 text-gray-400 text-sm">{monthlyMonth} 无办理记录</div>
|
||||
}
|
||||
const sConfigs = monthlyChanges.social?.configs || {}
|
||||
const hConfigs = monthlyChanges.housing?.configs || {}
|
||||
// 收集所有涉及的城市
|
||||
const allCities = [...new Set([
|
||||
...sAdd.map((i: any) => i.city), ...sSub.map((i: any) => i.city), ...sNormal.map((i: any) => i.city),
|
||||
...hAdd.map((i: any) => i.city), ...hSub.map((i: any) => i.city), ...hNormal.map((i: any) => i.city),
|
||||
])].filter(Boolean).sort()
|
||||
const renderSocialTable = (city: string) => {
|
||||
const add = sAdd.filter((i: any) => i.city === city)
|
||||
const sub = sSub.filter((i: any) => i.city === city)
|
||||
const normal = sNormal.filter((i: any) => i.city === city)
|
||||
if (add.length === 0 && sub.length === 0 && normal.length === 0) return null
|
||||
const cfg = sConfigs[city]
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">姓名</th>
|
||||
<th className="py-2 text-left">部门</th>
|
||||
<th className="py-2 text-left">类型</th>
|
||||
<th className="py-2 text-right">基数</th>
|
||||
<th className="py-2 text-right">企业部分</th>
|
||||
<th className="py-2 text-right">个人部分</th>
|
||||
<th className="py-2 text-right">合计</th>
|
||||
<th className="py-2 text-left">起止年月</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{add.map((i: any) => <MonthlyRow key={`sa-${city}-${i.employeeId}`} item={i} type="add" />)}
|
||||
{sub.map((i: any) => <MonthlyRow key={`ss-${city}-${i.employeeId}`} item={i} type="sub" />)}
|
||||
{normal.map((i: any) => <MonthlyRow key={`sn-${city}-${i.employeeId}`} item={i} type="normal" />)}
|
||||
</tbody>
|
||||
{(add.length > 0 || normal.length > 0) && (
|
||||
<tfoot>
|
||||
<tr className="border-t-2 bg-gray-50 font-medium">
|
||||
<td className="py-2" colSpan={4}>社保小计</td>
|
||||
<td className="py-2 text-right text-danger">¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.totalOrg || 0), 0))}</td>
|
||||
<td className="py-2 text-right text-warning">¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.totalEmp || 0), 0))}</td>
|
||||
<td className="py-2 text-right font-bold text-primary">¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.total || 0), 0))}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
{cfg && <div className="text-xs text-gray-400 mt-1">配置版本:{cfg.effectiveFrom} | 基数范围 ¥{fmt(cfg.baseMin)}~¥{fmt(cfg.baseMax)}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const renderHousingTable = (city: string) => {
|
||||
const add = hAdd.filter((i: any) => i.city === city)
|
||||
const sub = hSub.filter((i: any) => i.city === city)
|
||||
const normal = hNormal.filter((i: any) => i.city === city)
|
||||
if (add.length === 0 && sub.length === 0 && normal.length === 0) return null
|
||||
const cfg = hConfigs[city]
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">姓名</th>
|
||||
<th className="py-2 text-left">部门</th>
|
||||
<th className="py-2 text-left">类型</th>
|
||||
<th className="py-2 text-right">基数</th>
|
||||
<th className="py-2 text-right">企业部分</th>
|
||||
<th className="py-2 text-right">个人部分</th>
|
||||
<th className="py-2 text-right">合计</th>
|
||||
<th className="py-2 text-left">起止年月</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{add.map((i: any) => <MonthlyHousingRow key={`ha-${city}-${i.employeeId}`} item={i} type="add" />)}
|
||||
{sub.map((i: any) => <MonthlyHousingRow key={`hs-${city}-${i.employeeId}`} item={i} type="sub" />)}
|
||||
{normal.map((i: any) => <MonthlyHousingRow key={`hn-${city}-${i.employeeId}`} item={i} type="normal" />)}
|
||||
</tbody>
|
||||
{(add.length > 0 || normal.length > 0) && (
|
||||
<tfoot>
|
||||
<tr className="border-t-2 bg-gray-50 font-medium">
|
||||
<td className="py-2" colSpan={4}>公积金小计</td>
|
||||
<td className="py-2 text-right text-danger">¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.orgAmount || 0), 0))}</td>
|
||||
<td className="py-2 text-right text-warning">¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.empAmount || 0), 0))}</td>
|
||||
<td className="py-2 text-right font-bold text-primary">¥{fmt([...add, ...normal].reduce((s: number, i: any) => s + (i.detail?.total || 0), 0))}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
{cfg && <div className="text-xs text-gray-400 mt-1">配置版本:{cfg.effectiveFrom} | 企业 {cfg.housingOrg}% / 个人 {cfg.housingEmp}%</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 社保 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">社保</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">姓名</th>
|
||||
<th className="py-2 text-left">部门</th>
|
||||
<th className="py-2 text-left">类型</th>
|
||||
<th className="py-2 text-right">基数</th>
|
||||
<th className="py-2 text-left">开始年月</th>
|
||||
<th className="py-2 text-left">截止年月</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sAdd.map((i: any) => (
|
||||
<tr key={`sa-${i.employeeId}`} className="border-b last:border-0">
|
||||
<td className="py-1.5">{i.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
||||
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-green-50 text-safe">新增</span></td>
|
||||
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
|
||||
<td className="py-1.5">{i.startMonth}</td>
|
||||
<td className="py-1.5 text-gray-400">—</td>
|
||||
</tr>
|
||||
))}
|
||||
{sSub.map((i: any) => (
|
||||
<tr key={`ss-${i.employeeId}`} className="border-b last:border-0">
|
||||
<td className="py-1.5">{i.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
||||
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-red-50 text-danger">减少</span></td>
|
||||
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
|
||||
<td className="py-1.5 text-gray-400">—</td>
|
||||
<td className="py-1.5">{i.endMonth}</td>
|
||||
</tr>
|
||||
))}
|
||||
{sNormal.map((i: any) => (
|
||||
<tr key={`sn-${i.employeeId}`} className="border-b last:border-0">
|
||||
<td className="py-1.5">{i.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
||||
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-gray-100 text-gray-500">正常</span></td>
|
||||
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
|
||||
<td className="py-1.5 text-gray-400">{i.startMonth}</td>
|
||||
<td className="py-1.5 text-gray-400">{i.endMonth || '在保'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/* 公积金 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">公积金</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">姓名</th>
|
||||
<th className="py-2 text-left">部门</th>
|
||||
<th className="py-2 text-left">类型</th>
|
||||
<th className="py-2 text-right">基数</th>
|
||||
<th className="py-2 text-left">开始年月</th>
|
||||
<th className="py-2 text-left">截止年月</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hAdd.map((i: any) => (
|
||||
<tr key={`ha-${i.employeeId}`} className="border-b last:border-0">
|
||||
<td className="py-1.5">{i.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
||||
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-green-50 text-safe">新增</span></td>
|
||||
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
|
||||
<td className="py-1.5">{i.startMonth}</td>
|
||||
<td className="py-1.5 text-gray-400">—</td>
|
||||
</tr>
|
||||
))}
|
||||
{hSub.map((i: any) => (
|
||||
<tr key={`hs-${i.employeeId}`} className="border-b last:border-0">
|
||||
<td className="py-1.5">{i.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
||||
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-red-50 text-danger">减少</span></td>
|
||||
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
|
||||
<td className="py-1.5 text-gray-400">—</td>
|
||||
<td className="py-1.5">{i.endMonth}</td>
|
||||
</tr>
|
||||
))}
|
||||
{hNormal.map((i: any) => (
|
||||
<tr key={`hn-${i.employeeId}`} className="border-b last:border-0">
|
||||
<td className="py-1.5">{i.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
||||
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-gray-100 text-gray-500">正常</span></td>
|
||||
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
|
||||
<td className="py-1.5 text-gray-400">{i.startMonth}</td>
|
||||
<td className="py-1.5 text-gray-400">{i.endMonth || '在保'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{allCities.map((city) => {
|
||||
const sTable = renderSocialTable(city)
|
||||
const hTable = renderHousingTable(city)
|
||||
if (!sTable && !hTable) return null
|
||||
return (
|
||||
<div key={city} className="border rounded-lg p-3">
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 rounded bg-indigo-50 text-indigo-600 text-xs">{city}</span>
|
||||
<span className="text-gray-400 text-xs">向{city}社保/公积金经办机构申报</span>
|
||||
</h3>
|
||||
{sTable && <div className="mb-3"><h4 className="text-xs font-medium text-gray-600 mb-1">社保</h4>{sTable}</div>}
|
||||
{hTable && <div><h4 className="text-xs font-medium text-gray-600 mb-1">公积金</h4>{hTable}</div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
@@ -760,3 +911,72 @@ export default function SocialInsurance() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 月度办理社保行组件(可展开查看各险种明细) */
|
||||
function MonthlyRow({ item: i, type }: { item: any; type: 'add' | 'sub' | 'normal' }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常'
|
||||
const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500'
|
||||
const d = i.detail
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => setExpanded(!expanded)}>
|
||||
<td className="py-1.5">{i.name} {d && <span className="text-gray-300 text-xs">{expanded ? '▾' : '▸'}</span>}</td>
|
||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
||||
<td className="py-1.5"><span className={`px-2 py-0.5 rounded text-xs ${typeClass}`}>{typeLabel}</span></td>
|
||||
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
|
||||
<td className="py-1.5 text-right text-danger">{d ? `¥${fmt(d.totalOrg)}` : '-'}</td>
|
||||
<td className="py-1.5 text-right text-warning">{d ? `¥${fmt(d.totalEmp)}` : '-'}</td>
|
||||
<td className="py-1.5 text-right font-medium text-primary">{d ? `¥${fmt(d.total)}` : '-'}</td>
|
||||
<td className="py-1.5 text-gray-400 text-xs">{type === 'add' ? `${i.startMonth} →` : type === 'sub' ? `→ ${i.endMonth}` : `${i.startMonth} ~ ${i.endMonth || '在保'}`}</td>
|
||||
</tr>
|
||||
{expanded && d && (
|
||||
<tr className="bg-gray-50/50">
|
||||
<td colSpan={8} className="py-2 px-8">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-400">
|
||||
<th className="py-1 text-left">险种</th>
|
||||
<th className="py-1 text-right">企业比例</th>
|
||||
<th className="py-1 text-right">个人比例</th>
|
||||
<th className="py-1 text-right">企业缴纳</th>
|
||||
<th className="py-1 text-right">个人缴纳</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{d.items.map((item: any) => (
|
||||
<tr key={item.name} className="border-b last:border-0">
|
||||
<td className="py-1">{item.name}</td>
|
||||
<td className="py-1 text-right text-gray-500">{item.orgRate}%</td>
|
||||
<td className="py-1 text-right text-gray-500">{item.empRate > 0 ? `${item.empRate}%` : '-'}</td>
|
||||
<td className="py-1 text-right">¥{fmt(item.orgAmount)}</td>
|
||||
<td className="py-1 text-right">{item.empAmount > 0 ? `¥${fmt(item.empAmount)}` : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 月度办理公积金行组件 */
|
||||
function MonthlyHousingRow({ item: i, type }: { item: any; type: 'add' | 'sub' | 'normal' }) {
|
||||
const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常'
|
||||
const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500'
|
||||
const d = i.detail
|
||||
return (
|
||||
<tr className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-1.5">{i.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
||||
<td className="py-1.5"><span className={`px-2 py-0.5 rounded text-xs ${typeClass}`}>{typeLabel}</span></td>
|
||||
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
|
||||
<td className="py-1.5 text-right text-danger">{d ? `¥${fmt(d.orgAmount)}` : '-'}</td>
|
||||
<td className="py-1.5 text-right text-warning">{d ? `¥${fmt(d.empAmount)}` : '-'}</td>
|
||||
<td className="py-1.5 text-right font-medium text-primary">{d ? `¥${fmt(d.total)}` : '-'}</td>
|
||||
<td className="py-1.5 text-gray-400 text-xs">{type === 'add' ? `${i.startMonth} →` : type === 'sub' ? `→ ${i.endMonth}` : `${i.startMonth} ~ ${i.endMonth || '在保'}`}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -329,6 +329,7 @@ export default function Termination() {
|
||||
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
setView('list')
|
||||
},
|
||||
onError: () => toast.error('撤销失败'),
|
||||
|
||||
Reference in New Issue
Block a user