diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 34a0e1c..8299caa 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -128,8 +128,9 @@ model Organization { salaryChangeRecords SalaryChangeRecord[] onboardingLinks OnboardingLink[] confirmLinks ContractConfirmLink[] - socialInsuranceConfig SocialInsuranceConfig? + socialInsuranceConfig SocialInsuranceConfig[] notificationSetting NotificationSetting? + overtimeConfig OvertimeConfig? notificationLogs NotificationLog[] employeeAttachments EmployeeAttachment[] disciplinaryRecords DisciplinaryRecord[] @@ -236,6 +237,7 @@ model OvertimeRecord { weekendPay Float @default(0) holidayPay Float @default(0) totalPay Float @default(0) + batchId String? // 关联的发薪批次(加入后锁定,不可重复加入) createdAt DateTime @default(now()) @@unique([employeeId, month]) @@ -298,7 +300,7 @@ model AuditLog { model SocialInsuranceConfig { id String @id @default(cuid()) - orgId String @unique + orgId String org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) city String @default("北京") pensionOrg Float @default(16) // 养老保险 企业比例 % @@ -313,7 +315,16 @@ model SocialInsuranceConfig { housingEmp Float @default(12) // 公积金 个人比例 % baseMin Float @default(6326) // 缴费基数下限 baseMax Float @default(33891) // 缴费基数上限 + effectiveFrom String // 生效月份 YYYY-MM + effectiveTo String? // 失效月份 YYYY-MM(null=当前有效) + isCurrent Boolean @default(true) // 是否当前生效版本 + adjustmentDone Boolean @default(false) // 是否已执行过员工基数调整 + createdBy String + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@unique([orgId, effectiveFrom]) + @@index([orgId, isCurrent]) } model NotificationSetting { @@ -336,6 +347,18 @@ model NotificationSetting { updatedAt DateTime @updatedAt } +model OvertimeConfig { + id String @id @default(cuid()) + orgId String @unique + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + weekdayRate Float @default(1.5) // 工作日加班倍率 + weekendRate Float @default(2.0) // 休息日加班倍率 + holidayRate Float @default(3.0) // 法定节假日加班倍率 + monthlyDays Float @default(21.75) // 月计薪天数 + dailyHours Float @default(8) // 每日工时 + updatedAt DateTime @updatedAt +} + model NotificationLog { id String @id @default(cuid()) orgId String diff --git a/backend/prisma/seed-wufang.ts b/backend/prisma/seed-wufang.ts new file mode 100644 index 0000000..19ba91c --- /dev/null +++ b/backend/prisma/seed-wufang.ts @@ -0,0 +1,120 @@ +import prisma from '../src/lib/prisma' + +const EID = 'cmrx61v6d001oqqcwb2pu2tih' +const ORGID = 'cmrx61v3l0000qqcwo3dr3h95' +const UID = 'cmrx61v5u0002qqcwqf4vlyth' + +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 }, + ] + for (const o of otMonths) { + const existing = await prisma.overtimeRecord.findUnique({ where: { employeeId_month: { employeeId: EID, month: o.month } } }) + if (!existing) { + await prisma.overtimeRecord.create({ data: { orgId: ORGID, employeeId: EID, month: o.month, weekdayHours: o.wh, weekendHours: o.weh, holidayHours: o.hh, weekdayPay: o.wp, weekendPay: o.wep, holidayPay: o.hp, totalPay: o.pay } }) + } + } + console.log('加班记录: 完成') + + // 违纪记录 + 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: '王强' }, + ] + for (const d of discRecords) { + const existing = await prisma.disciplinaryRecord.findFirst({ where: { employeeId: EID, violationDate: d.violationDate } }) + if (!existing) { + await prisma.disciplinaryRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...d } }) + } + } + console.log('违纪记录: 完成') + + // 考勤记录 - 最近10个工作日 + 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 }, + ] + 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 } }) + } + } + console.log('考勤记录: 完成') + + // 培训签收记录 + 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: '待员工签收确认' }, + ] + for (const t of trainings) { + const existing = await prisma.trainingRecord.findFirst({ where: { employeeId: EID, trainingDate: t.trainingDate } }) + if (!existing) { + await prisma.trainingRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...t } }) + } + } + 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-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: '王强' }, + ] + for (const p of performances) { + const existing = await prisma.performanceRecord.findUnique({ where: { employeeId_period: { employeeId: EID, period: p.period } } }) + if (!existing) { + await prisma.performanceRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...p } }) + } + } + console.log('绩效记录: 完成') + + // 附件 + const attachments = [ + { fileName: '吴芳身份证扫描件.pdf', fileType: 'ID_CARD', fileUrl: 'data:application/pdf;base64,placeholder', fileSize: 102400 }, + { 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 }, + ] + for (const a of attachments) { + const existing = await prisma.employeeAttachment.findFirst({ where: { employeeId: EID, fileName: a.fileName } }) + if (!existing) { + await prisma.employeeAttachment.create({ data: { ...a, orgId: ORGID, employeeId: EID, uploadedBy: UID } }) + } + } + 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 } + }) + if (emp) { + console.log('--- 吴芳完整档案数据统计 ---') + console.log('contracts:', emp.contracts.length) + console.log('payslips:', emp.payslips.length) + console.log('overtimeRecords:', emp.overtimeRecords.length) + console.log('disciplinaryRecords:', emp.disciplinaryRecords.length) + 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('attachments:', emp.attachments.length) + } + await prisma.$disconnect() +} + +main().catch(console.error) diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index e13805d..0b9019e 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -32,15 +32,15 @@ function calcTax(taxableIncome: number): number { // 9名员工完整数据 const EMPLOYEES = [ - { name: '张伟', gender: '男', dept: '技术部', phone: '13900000001', salary: 18000, hireDate: '2023-03-01', socialBase: 18000, housingBase: 18000, specialDeduction: 2000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 14400, bank: '工商银行', account: '6222021234567890001', emergency: '张父', emergencyPhone: '13800001001', address: '上海市浦东新区张江路100号' }, - { name: '李娜', gender: '女', dept: '技术部', phone: '13900000002', salary: 15000, hireDate: '2023-06-15', socialBase: 15000, housingBase: 15000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 12000, bank: '建设银行', account: '6227001234567890002', emergency: '李母', emergencyPhone: '13800001002', address: '上海市徐汇区漕河泾50号', pregnant: true }, - { name: '王强', gender: '男', dept: '销售部', phone: '13900000003', salary: 12000, hireDate: '2024-01-10', socialBase: 12000, housingBase: 12000, specialDeduction: 3000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 9600, bank: '招商银行', account: '6225881234567890003', emergency: '王妻', emergencyPhone: '13800001003', address: '上海市闵行区莘庄路200号' }, - { name: '赵敏', gender: '女', dept: '人事部', phone: '13900000004', salary: 10000, hireDate: '2022-09-01', socialBase: 10000, housingBase: 10000, specialDeduction: 1500, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '农业银行', account: '6228481234567890004', emergency: '赵父', emergencyPhone: '13800001004', address: '上海市黄浦区南京东路300号' }, - { name: '陈刚', gender: '男', dept: '销售部', phone: '13900000005', salary: 8000, hireDate: '2024-07-01', socialBase: 8000, housingBase: 8000, specialDeduction: 0, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 6400, bank: '中国银行', account: '6217001234567890005', emergency: '陈母', emergencyPhone: '13800001005', address: '上海市杨浦区五角场400号' }, - { name: '刘洋', gender: '男', dept: '技术部', phone: '13900000006', salary: 22000, hireDate: '2021-04-01', socialBase: 33891, housingBase: 33891, specialDeduction: 4000, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '交通银行', account: '6222601234567890006', emergency: '刘妻', emergencyPhone: '13800001006', address: '上海市长宁区中山公园500号' }, - { name: '周婷', gender: '女', dept: '财务部', phone: '13900000007', salary: 13000, hireDate: '2023-11-15', socialBase: 13000, housingBase: 13000, specialDeduction: 2500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 10400, bank: '浦发银行', account: '6225161234567890007', emergency: '周父', emergencyPhone: '13800001007', address: '上海市静安区南京西路600号' }, - { name: '孙磊', gender: '男', dept: '技术部', phone: '13900000008', salary: 16000, hireDate: '2022-06-01', socialBase: 16000, housingBase: 16000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 12800, bank: '民生银行', account: '6226161234567890008', emergency: '孙母', emergencyPhone: '13800001008', address: '上海市虹口区四川北路700号' }, - { name: '吴芳', gender: '女', dept: '销售部', phone: '13900000009', salary: 9000, hireDate: '2025-02-15', socialBase: 9000, housingBase: 9000, specialDeduction: 500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 7200, bank: '光大银行', account: '6226621234567890009', emergency: '吴夫', emergencyPhone: '13800001009', address: '上海市宝山区牡丹江路800号' }, + { name: '张伟', gender: '男', dept: '技术部', phone: '13900000001', idCard: '310101199001011234', salary: 18000, hireDate: '2023-03-01', socialBase: 18000, housingBase: 18000, specialDeduction: 2000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 14400, bank: '工商银行', account: '6222021234567890001', emergency: '张父', emergencyPhone: '13800001001', address: '上海市浦东新区张江路100号' }, + { name: '李娜', gender: '女', dept: '技术部', phone: '13900000002', idCard: '310102199203052345', salary: 15000, hireDate: '2023-06-15', socialBase: 15000, housingBase: 15000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 12000, bank: '建设银行', account: '6227001234567890002', emergency: '李母', emergencyPhone: '13800001002', address: '上海市徐汇区漕河泾50号', pregnant: true }, + { name: '王强', gender: '男', dept: '销售部', phone: '13900000003', idCard: '310103198812103456', salary: 12000, hireDate: '2024-01-10', socialBase: 12000, housingBase: 12000, specialDeduction: 3000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 9600, bank: '招商银行', account: '6225881234567890003', emergency: '王妻', emergencyPhone: '13800001003', address: '上海市闵行区莘庄路200号' }, + { name: '赵敏', gender: '女', dept: '人事部', phone: '13900000004', idCard: '310104199506154567', salary: 10000, hireDate: '2022-09-01', socialBase: 10000, housingBase: 10000, specialDeduction: 1500, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '农业银行', account: '6228481234567890004', emergency: '赵父', emergencyPhone: '13800001004', address: '上海市黄浦区南京东路300号' }, + { name: '陈刚', gender: '男', dept: '销售部', phone: '13900000005', idCard: '310105199907205678', salary: 8000, hireDate: '2024-07-01', socialBase: 8000, housingBase: 8000, specialDeduction: 0, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 6400, bank: '中国银行', account: '6217001234567890005', emergency: '陈母', emergencyPhone: '13800001005', address: '上海市杨浦区五角场400号' }, + { name: '刘洋', gender: '男', dept: '技术部', phone: '13900000006', idCard: '310106198504016789', salary: 22000, hireDate: '2021-04-01', socialBase: 33891, housingBase: 33891, specialDeduction: 4000, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '交通银行', account: '6222601234567890006', emergency: '刘妻', emergencyPhone: '13800001006', address: '上海市长宁区中山公园500号' }, + { name: '周婷', gender: '女', dept: '财务部', phone: '13900000007', idCard: '310107199311157890', salary: 13000, hireDate: '2023-11-15', socialBase: 13000, housingBase: 13000, specialDeduction: 2500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 10400, bank: '浦发银行', account: '6225161234567890007', emergency: '周父', emergencyPhone: '13800001007', address: '上海市静安区南京西路600号' }, + { name: '孙磊', gender: '男', dept: '技术部', phone: '13900000008', idCard: '310108199008018901', salary: 16000, hireDate: '2022-06-01', socialBase: 16000, housingBase: 16000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 12800, bank: '民生银行', account: '6226161234567890008', emergency: '孙母', emergencyPhone: '13800001008', address: '上海市虹口区四川北路700号' }, + { name: '吴芳', gender: '女', dept: '销售部', phone: '13900000009', idCard: '310109199702159012', salary: 9000, hireDate: '2025-02-15', socialBase: 9000, housingBase: 9000, specialDeduction: 500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 7200, bank: '光大银行', account: '6226621234567890009', emergency: '吴夫', emergencyPhone: '13800001009', address: '上海市宝山区牡丹江路800号' }, ] async function main() { @@ -113,6 +113,8 @@ async function main() { housingEmp: 7, baseMin: 7384, baseMax: 36921, + effectiveFrom: '2025-07', + createdBy: admin.id, }, }) console.log('社保配置已创建') @@ -164,6 +166,7 @@ async function main() { hireDate: new Date(e.hireDate), monthlySalary: encrypt(String(e.salary)), phone: e.phone, + idCardNumber: encrypt(e.idCard), gender: e.gender, socialInsBase: e.socialBase, housingFundBase: e.housingBase, @@ -204,7 +207,7 @@ async function main() { // 8. 生成 1-6 月历史工资条(已发布),使 7 月累计预扣个税有 YTD 数据 console.log('\n生成 1-6 月历史工资条...') - const socialConfig = await prisma.socialInsuranceConfig.findUnique({ where: { orgId: org.id } }) + const socialConfig = await prisma.socialInsuranceConfig.findFirst({ where: { orgId: org.id, isCurrent: true } }) const allEmployees = await prisma.employee.findMany({ where: { orgId: org.id } }) const months = ['2026-01', '2026-02', '2026-03', '2026-04', '2026-05', '2026-06'] diff --git a/backend/src/routes/payroll.routes.ts b/backend/src/routes/payroll.routes.ts index 5ef461f..dc41e8e 100644 --- a/backend/src/routes/payroll.routes.ts +++ b/backend/src/routes/payroll.routes.ts @@ -283,13 +283,50 @@ router.post('/payslip/batch-generate', async (req: AuthRequest, res: Response, n } }) -// ========== 批量导入加班数据 ========== +// ========== 加班费计算规则配置 ========== + +const overtimeConfigSchema = z.object({ + weekdayRate: z.number().min(1).default(1.5), + weekendRate: z.number().min(1).default(2.0), + holidayRate: z.number().min(1).default(3.0), + monthlyDays: z.number().min(1).default(21.75), + dailyHours: z.number().min(1).default(8), +}) + +// 获取加班费计算规则 +router.get('/overtime/config', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + let config = await prisma.overtimeConfig.findUnique({ where: { orgId: req.user!.orgId } }) + if (!config) { + config = await prisma.overtimeConfig.create({ data: { orgId: req.user!.orgId } }) + } + res.json({ success: true, data: config }) + } catch (err) { + next(err) + } +}) + +// 保存加班费计算规则 +router.post('/overtime/config', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = overtimeConfigSchema.parse(req.body) + const config = await prisma.overtimeConfig.upsert({ + where: { orgId: req.user!.orgId }, + update: data, + create: { orgId: req.user!.orgId, ...data }, + }) + res.json({ success: true, data: config }) + } catch (err) { + next(err) + } +}) + +// ========== 批量导入加班工时 ========== const batchOvertimeSchema = z.array( z.object({ employeeId: z.string().min(1), month: z.string().regex(/^\d{4}-\d{2}$/), - monthlyWage: z.number().positive(), weekdayHours: z.number().min(0).default(0), weekendHours: z.number().min(0).default(0), holidayHours: z.number().min(0).default(0), @@ -302,19 +339,13 @@ router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: Nex const results: any[] = [] for (const data of items) { - const hourlyWage = data.monthlyWage / 21.75 / 8 - const weekdayPay = hourlyWage * 1.5 * data.weekdayHours - const weekendPay = hourlyWage * 2.0 * data.weekendHours - const holidayPay = hourlyWage * 3.0 * data.holidayHours - const totalPay = weekdayPay + weekendPay + holidayPay - const record = await prisma.overtimeRecord.upsert({ where: { employeeId_month: { employeeId: data.employeeId, month: data.month } }, update: { weekdayHours: data.weekdayHours, weekendHours: data.weekendHours, holidayHours: data.holidayHours, - weekdayPay, weekendPay, holidayPay, totalPay, + weekdayPay: 0, weekendPay: 0, holidayPay: 0, totalPay: 0, }, create: { orgId: req.user!.orgId, @@ -323,7 +354,6 @@ router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: Nex weekdayHours: data.weekdayHours, weekendHours: data.weekendHours, holidayHours: data.holidayHours, - weekdayPay, weekendPay, holidayPay, totalPay, }, }) results.push(record) @@ -335,4 +365,80 @@ router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: Nex } }) +// ========== 批次导入加班费 ========== + +router.post('/overtime/import-to-batch/:batchId', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { batchId } = req.params + const orgId = req.user!.orgId + + const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } }) + if (!batch) return res.status(404).json({ success: false, message: '批次不存在' }) + if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, message: '已归档批次不可操作' }) + + // 获取加班费计算规则 + let config = await prisma.overtimeConfig.findUnique({ where: { orgId } }) + if (!config) config = await prisma.overtimeConfig.create({ data: { orgId } }) + const cfg = config + + // 获取该月未关联批次的加班记录 + const overtimeRecords = await prisma.overtimeRecord.findMany({ + where: { orgId, month: batch.month, batchId: null }, + include: { employee: { select: { id: true, name: true, monthlySalary: true } } }, + }) + + if (overtimeRecords.length === 0) { + return res.json({ success: false, message: '没有可导入的加班记录(所有记录已关联批次或无数据)' }) + } + + const results: any[] = [] + for (const ot of overtimeRecords) { + // 获取员工月工资 + let monthlyWage = 0 + try { + monthlyWage = ot.employee.monthlySalary ? Number(decrypt(ot.employee.monthlySalary)) : 0 + } catch { + monthlyWage = Number(ot.employee.monthlySalary) || 0 + } + if (!monthlyWage) continue + + // 根据规则计算加班费 + const hourlyWage = monthlyWage / config.monthlyDays / config.dailyHours + const weekdayPay = hourlyWage * config.weekdayRate * ot.weekdayHours + const weekendPay = hourlyWage * config.weekendRate * ot.weekendHours + const holidayPay = hourlyWage * config.holidayRate * ot.holidayHours + const totalPay = weekdayPay + weekendPay + holidayPay + + // 更新加班记录:计算金额并锁定到批次 + await prisma.overtimeRecord.update({ + where: { id: ot.id }, + data: { weekdayPay, weekendPay, holidayPay, totalPay, batchId }, + }) + + // 更新批次条目的加班费 + const entry = await prisma.batchEntry.findUnique({ + where: { batchId_employeeId: { batchId, employeeId: ot.employeeId } }, + }) + if (entry) { + await prisma.batchEntry.update({ + where: { id: entry.id }, + data: { overtimePay: totalPay }, + }) + // 重新计算条目 + const newTotalPay = entry.baseSalary + totalPay + entry.allowance + entry.bonus - entry.deduction + await prisma.batchEntry.update({ + where: { id: entry.id }, + data: { totalPay: newTotalPay }, + }) + } + + results.push({ employeeId: ot.employeeId, employeeName: ot.employee.name, totalPay }) + } + + res.json({ success: true, data: { imported: results.length, details: results } }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index 89931b9..741f793 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -125,6 +125,22 @@ router.get('/batches/check', async (req: AuthRequest, res: Response, next: NextF } }) +// 获取可复制的归档批次列表 +router.get('/batches/archived/list', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const batches = await prisma.payrollBatch.findMany({ + where: { orgId, status: 'ARCHIVED' }, + orderBy: [{ month: 'desc' }, { batchNo: 'desc' }], + select: { id: true, name: true, month: true, type: true, employeeCount: true, totalPay: true, totalNetPay: true }, + take: 20, + }) + res.json({ success: true, data: batches }) + } catch (err) { + next(err) + } +}) + // 获取批次列表 router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => { try { @@ -167,13 +183,15 @@ router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFun const createBatchSchema = z.object({ month: z.string().regex(/^\d{4}-\d{2}$/), type: z.enum(['REGULAR', 'TERMINATION', 'BONUS']).default('REGULAR'), + mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch']).default('copy_last'), + sourceBatchId: z.string().optional(), name: z.string().optional(), remark: z.string().optional(), }) router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => { try { - const { month, type, name, remark } = createBatchSchema.parse(req.body) + const { month, type, mode, sourceBatchId, name, remark } = createBatchSchema.parse(req.body) const orgId = req.user!.orgId // 查询当月已有批次数 @@ -189,34 +207,55 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti const monthStart = new Date(`${month}-01`) const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0, 23, 59, 59) - let employees: any[] - if (type === 'TERMINATION') { - // 离职结算批次:本月离职员工 - const terminations = await prisma.terminationRecord.findMany({ - where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } }, - include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } }, - }) - employees = terminations.map(t => t.employee) - } else { - // 常规/奖金批次:在职 + 本月离职 - employees = await prisma.employee.findMany({ - where: { - orgId, - OR: [ - { status: 'ACTIVE' }, - { status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } }, - ], - }, - include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } }, - }) - } - - // 获取上月发薪数据作为默认值 + // 获取上月发薪数据 const prevMonth = new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1) const prevMonthStr = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, '0')}` const batchName = name || `${month} 第${batchNo}批 ${type === 'BONUS' ? '奖金' : type === 'TERMINATION' ? '离职结算' : '发薪'}` + // 根据模式确定员工列表和数据来源 + let employees: any[] = [] + let sourceEntries: any[] | null = null + + if (mode === 'blank_all') { + // 全空白:不拉入员工 + employees = [] + } else if (mode === 'copy_batch' && sourceBatchId) { + // 复制指定批次:从源批次复制条目 + const sourceBatch = await prisma.payrollBatch.findFirst({ + where: { id: sourceBatchId, orgId, status: 'ARCHIVED' }, + include: { entries: true }, + }) + if (!sourceBatch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '源批次不存在或未归档' } }) + sourceEntries = sourceBatch.entries + // 提取员工 ID,后续按此创建条目 + const employeeIds = sourceEntries.map(e => e.employeeId) + employees = await prisma.employee.findMany({ + where: { id: { in: employeeIds }, orgId }, + include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } }, + }) + } else { + // copy_last 或 blank_employees:拉入员工 + if (type === 'TERMINATION') { + const terminations = await prisma.terminationRecord.findMany({ + where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } }, + include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } }, + }) + employees = terminations.map(t => t.employee) + } else { + employees = await prisma.employee.findMany({ + where: { + orgId, + OR: [ + { status: 'ACTIVE' }, + { status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } }, + ], + }, + include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } }, + }) + } + } + // 创建批次 const batch = await prisma.payrollBatch.create({ data: { @@ -234,36 +273,52 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti // 创建批次条目 const entries: any[] = [] for (const emp of employees) { - // 获取上次发薪数据 - const prevPayslip = await prisma.payslip.findUnique({ - where: { employeeId_month: { employeeId: emp.id, month: prevMonthStr } }, - }) - - // 获取加班费 - const overtime = await prisma.overtimeRecord.findUnique({ - where: { employeeId_month: { employeeId: emp.id, month } }, - }) - - // 基本工资 let baseSalary = 0 - if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) { - baseSalary = emp.contracts[0].probationSalary - } else if (emp.monthlySalary) { - try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 } - } + let overtimePay = 0 + let allowance = 0 + let deduction = 0 + let bonus = 0 - // 如果有上次发薪数据,带入 - if (prevPayslip) { - baseSalary = prevPayslip.baseSalary - } + if (mode === 'copy_batch' && sourceEntries) { + // 复制指定批次:从源条目复制数据 + const srcEntry = sourceEntries.find(e => e.employeeId === emp.id) + if (srcEntry) { + baseSalary = srcEntry.baseSalary + overtimePay = srcEntry.overtimePay + allowance = srcEntry.allowance + deduction = srcEntry.deduction + bonus = srcEntry.bonus + } + } else if (mode === 'copy_last') { + // 复制上月:从上月工资条复制 + const prevPayslip = await prisma.payslip.findUnique({ + where: { employeeId_month: { employeeId: emp.id, month: prevMonthStr } }, + }) + const overtime = await prisma.overtimeRecord.findUnique({ + where: { employeeId_month: { employeeId: emp.id, month } }, + }) - const overtimePay = overtime?.totalPay || 0 - const allowance = prevPayslip?.allowance || 0 - const deduction = prevPayslip?.deduction || 0 - const bonus = type === 'BONUS' ? 0 : 0 // 奖金批次默认0,手动填写 + if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) { + baseSalary = emp.contracts[0].probationSalary + } else if (emp.monthlySalary) { + try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 } + } + if (prevPayslip) baseSalary = prevPayslip.baseSalary + overtimePay = overtime?.totalPay || 0 + allowance = prevPayslip?.allowance || 0 + deduction = prevPayslip?.deduction || 0 + } + // blank_employees 和 blank_all: 所有金额默认 0 + + // 判断同月是否已有归档的常规批次(用于决定是否跳过社保) + const hasArchivedRegularBatch = await prisma.payrollBatch.count({ + where: { orgId, month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } }, + }) // 计算社保、个税等 - const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type) + // 同月已有归档常规批次时,新批次跳过社保(避免重复扣缴),但用户可手动编辑覆盖 + const skipSocial = type !== 'BONUS' && hasArchivedRegularBatch > 0 + const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { skipSocial }) // 风险提示 const riskWarnings = await getPayrollRiskWarnings(orgId, emp.id) @@ -322,13 +377,17 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti } }) -// 编辑批次条目(计算依据项) +// 编辑批次条目(计算依据项 + 社保公积金手动覆盖) const updateEntrySchema = z.object({ baseSalary: z.number().min(0).optional(), overtimePay: z.number().min(0).optional(), allowance: z.number().min(0).optional(), deduction: z.number().min(0).optional(), bonus: z.number().min(0).optional(), + socialEmp: z.number().min(0).optional(), + socialOrg: z.number().min(0).optional(), + housingEmp: z.number().min(0).optional(), + housingOrg: z.number().min(0).optional(), }) router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => { @@ -355,8 +414,16 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res bonus: data.bonus ?? entry.bonus, } + // 构建社保覆盖参数(如果请求中包含社保字段) + const overrideSocial: any = {} + if (data.socialEmp !== undefined) overrideSocial.socialEmp = data.socialEmp + if (data.socialOrg !== undefined) overrideSocial.socialOrg = data.socialOrg + if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp + if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg + const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined + // 重新计算 - const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type) + const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options) const updated = await prisma.batchEntry.update({ where: { id: entry.id }, @@ -368,14 +435,22 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res const totals = allEntries.reduce((acc, e) => ({ totalPay: acc.totalPay + (e.id === entry.id ? calcResult.totalPay : e.totalPay), totalNetPay: acc.totalNetPay + (e.id === entry.id ? calcResult.netPay : e.netPay), + totalSocialOrg: acc.totalSocialOrg + (e.id === entry.id ? calcResult.socialOrg : e.socialOrg), + totalSocialEmp: acc.totalSocialEmp + (e.id === entry.id ? calcResult.socialEmp : e.socialEmp), + totalHousingOrg: acc.totalHousingOrg + (e.id === entry.id ? calcResult.housingOrg : e.housingOrg), + totalHousingEmp: acc.totalHousingEmp + (e.id === entry.id ? calcResult.housingEmp : e.housingEmp), totalTax: acc.totalTax + (e.id === entry.id ? calcResult.tax : e.tax), - }), { totalPay: 0, totalNetPay: 0, totalTax: 0 }) + }), { 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, }, }) @@ -467,6 +542,25 @@ router.delete('/batches/:batchId/employees/:employeeId', async (req: AuthRequest } }) +// 删除批次(仅限草稿状态) +router.delete('/batches/:batchId', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { batchId } = req.params + const orgId = req.user!.orgId + + const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } }) + if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } }) + if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可删除' } }) + + await prisma.batchEntry.deleteMany({ where: { batchId } }) + await prisma.payrollBatch.delete({ where: { id: batchId } }) + + res.json({ success: true }) + } catch (err) { + next(err) + } +}) + // 归档批次 router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response, next: NextFunction) => { try { diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index e8ad79e..d3689d7 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -3,6 +3,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth' import { auditLog } from '../middleware/auditLog' import prisma from '../lib/prisma' import { decrypt } from '../lib/crypto' +import { getContractStatus } from '../services/contract.service' const router = Router() @@ -37,18 +38,39 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { }, }, }) - const result = employees.map((e) => ({ - id: e.id, - name: e.name, - department: e.department, - status: e.status, - hireDate: e.hireDate, - gender: e.gender, - phone: e.phone, - monthlySalary: safeDecrypt(e.monthlySalary), - latestContract: e.contracts[0] || null, - counts: e._count, - })) + const result = employees.map((e) => { + const latestContract = e.contracts[0] || null + const contractInfo = latestContract + ? getContractStatus({ + signDate: latestContract.signDate, + startDate: latestContract.startDate, + endDate: latestContract.endDate, + contractType: latestContract.contractType, + hireDate: e.hireDate, + }) + : getContractStatus({ + signDate: null, + startDate: e.hireDate, + endDate: null, + contractType: 'UNSIGNED', + hireDate: e.hireDate, + }) + return { + id: e.id, + name: e.name, + department: e.department, + status: e.status, + hireDate: e.hireDate, + gender: e.gender, + phone: e.phone, + monthlySalary: safeDecrypt(e.monthlySalary), + latestContract, + contractStatus: contractInfo.status, + contractStatusText: contractInfo.statusText, + riskLevel: contractInfo.riskLevel, + counts: e._count, + } + }) res.json({ success: true, data: result }) } catch (err) { next(err) @@ -75,10 +97,15 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) = if (!employee) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) } - const { monthlySalary, ...rest } = employee + const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee res.json({ success: true, - data: { ...rest, monthlySalary: safeDecrypt(monthlySalary) }, + data: { + ...rest, + monthlySalary: safeDecrypt(monthlySalary), + bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null, + idCardNumber: idCardNumber ? safeDecrypt(idCardNumber).toString() : null, + }, }) } catch (err) { next(err) diff --git a/backend/src/routes/social.routes.ts b/backend/src/routes/social.routes.ts index dd077fe..8fd1924 100644 --- a/backend/src/routes/social.routes.ts +++ b/backend/src/routes/social.routes.ts @@ -1,30 +1,13 @@ import { Router, Response, NextFunction } from 'express' import prisma from '../lib/prisma' import { authMiddleware, AuthRequest } from '../middleware/auth' +import { decrypt } from '../lib/crypto' import { z } from 'zod' const router = Router() router.use(authMiddleware) -// 获取社保配置 -router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => { - try { - let config = await prisma.socialInsuranceConfig.findUnique({ - where: { orgId: req.user!.orgId }, - }) - if (!config) { - config = await prisma.socialInsuranceConfig.create({ - data: { orgId: req.user!.orgId }, - }) - } - res.json({ success: true, data: config }) - } catch (err) { - next(err) - } -}) - -// 更新社保配置 -const configSchema = z.object({ +const configFields = { city: z.string().optional(), pensionOrg: z.number().optional(), pensionEmp: z.number().optional(), @@ -38,35 +21,269 @@ const configSchema = z.object({ housingEmp: z.number().optional(), baseMin: z.number().optional(), baseMax: z.number().optional(), -}) +} -router.put('/config', async (req: AuthRequest, res: Response, next: NextFunction) => { +// 获取当前生效版本 +router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => { try { - const data = configSchema.parse(req.body) - const config = await prisma.socialInsuranceConfig.upsert({ - where: { orgId: req.user!.orgId }, - update: data, - create: { orgId: req.user!.orgId, ...data }, + let config = await prisma.socialInsuranceConfig.findFirst({ + where: { orgId: req.user!.orgId, isCurrent: true }, + orderBy: { effectiveFrom: 'desc' }, }) + if (!config) { + config = await prisma.socialInsuranceConfig.create({ + data: { + orgId: req.user!.orgId, + effectiveFrom: new Date().toISOString().slice(0, 7), + createdBy: req.user!.id, + }, + }) + } res.json({ success: true, data: config }) } catch (err) { next(err) } }) -// 社保计算 +// 获取所有版本列表 +router.get('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const versions = await prisma.socialInsuranceConfig.findMany({ + where: { orgId: req.user!.orgId }, + orderBy: { effectiveFrom: 'desc' }, + }) + res.json({ success: true, data: versions }) + } catch (err) { + next(err) + } +}) + +// 按月份获取适用版本 +router.get('/config/by-month/:month', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { month } = req.params + const config = await prisma.socialInsuranceConfig.findFirst({ + where: { + orgId: req.user!.orgId, + effectiveFrom: { lte: month }, + OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }], + }, + orderBy: { effectiveFrom: 'desc' }, + }) + if (!config) { + // 回退到当前版本 + const current = await prisma.socialInsuranceConfig.findFirst({ + where: { orgId: req.user!.orgId, isCurrent: true }, + }) + return res.json({ success: true, data: current }) + } + res.json({ success: true, data: config }) + } catch (err) { + next(err) + } +}) + +// 新建版本(年度调基/比例变更) +const createVersionSchema = z.object({ + ...configFields, + effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/), +}) + +router.post('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = createVersionSchema.parse(req.body) + const orgId = req.user!.orgId + + // 检查同一生效月份是否已有版本 + const existing = await prisma.socialInsuranceConfig.findUnique({ + where: { orgId_effectiveFrom: { orgId, effectiveFrom: data.effectiveFrom } }, + }) + if (existing) { + return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有配置版本` }) + } + + // 将之前当前版本标记为失效 + const prevCurrent = await prisma.socialInsuranceConfig.findFirst({ + where: { orgId, isCurrent: true }, + }) + if (prevCurrent) { + // 计算上个版本的失效月份 = 新版本生效月份的前一个月 + const [year, mon] = data.effectiveFrom.split('-').map(Number) + const prevMonth = mon === 1 + ? `${year - 1}-12` + : `${year}-${String(mon - 1).padStart(2, '0')}` + await prisma.socialInsuranceConfig.update({ + where: { id: prevCurrent.id }, + data: { isCurrent: false, effectiveTo: prevMonth }, + }) + } + + // 创建新版本 + const version = await prisma.socialInsuranceConfig.create({ + data: { + orgId, + ...data, + isCurrent: true, + createdBy: req.user!.id, + }, + }) + res.json({ success: true, data: version }) + } catch (err) { + next(err) + } +}) + +// 预览员工基数调整(返回全部在职员工,含当前基数和建议基数) +router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params + const orgId = req.user!.orgId + + const config = await prisma.socialInsuranceConfig.findFirst({ + where: { id, orgId }, + }) + if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' }) + if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' }) + + const employees = await prisma.employee.findMany({ + where: { orgId, status: 'ACTIVE' }, + select: { id: true, name: true, department: true, socialInsBase: true, housingFundBase: true, monthlySalary: true }, + orderBy: { name: 'asc' }, + }) + + // 计算上年平均工资:查询过去12个月的Payslip的totalPay平均值 + const now = new Date() + const lastYearStart = `${now.getFullYear() - 1}-01` + const lastYearEnd = `${now.getFullYear() - 1}-12` + + const lastYearPayslips = await prisma.payslip.findMany({ + where: { + orgId, + month: { gte: lastYearStart, lte: lastYearEnd }, + }, + select: { employeeId: true, totalPay: true }, + }) + + // 按员工汇总上年月均工资 + const avgSalaryMap = new Map() + const empPayslipMap = new Map() + for (const p of lastYearPayslips) { + if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, []) + empPayslipMap.get(p.employeeId)!.push(p.totalPay) + } + for (const [empId, pays] of empPayslipMap) { + const avg = pays.reduce((s, v) => s + v, 0) / pays.length + avgSalaryMap.set(empId, avg) + } + + const items = employees.map((emp) => { + let monthlyWage = 0 + try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 } + const oldSocialBase = emp.socialInsBase ?? monthlyWage + const oldHousingBase = emp.housingFundBase ?? monthlyWage + const avgSalary = avgSalaryMap.get(emp.id) ?? monthlyWage + // 建议基数 = 上年月均工资按上下限裁剪 + const suggestedSocialBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax) + const suggestedHousingBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax) + return { + employeeId: emp.id, + name: emp.name, + department: emp.department, + oldSocialBase, + oldHousingBase, + avgSalary, + monthlyWage, + suggestedSocialBase, + suggestedHousingBase, + } + }) + + res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } }) + } catch (err) { + next(err) + } +}) + +// 执行员工基数调整(接收用户编辑后的数据) +const adjustApplySchema = z.object({ + items: z.array(z.object({ + employeeId: z.string(), + newSocialBase: z.number(), + newHousingBase: z.number(), + })), +}) + +router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params + const orgId = req.user!.orgId + + const config = await prisma.socialInsuranceConfig.findFirst({ + where: { id, orgId }, + }) + if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' }) + if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' }) + + const { items } = adjustApplySchema.parse(req.body) + + let adjusted = 0 + for (const item of items) { + // 裁剪到上下限范围内 + const socialBase = Math.min(Math.max(item.newSocialBase, config.baseMin), config.baseMax) + const housingBase = Math.min(Math.max(item.newHousingBase, config.baseMin), config.baseMax) + + await prisma.employee.update({ + where: { id: item.employeeId }, + data: { + socialInsBase: socialBase, + housingFundBase: housingBase, + }, + }) + adjusted++ + } + + await prisma.socialInsuranceConfig.update({ + where: { id }, + data: { adjustmentDone: true }, + }) + + res.json({ success: true, data: { adjusted, total: items.length } }) + } catch (err) { + next(err) + } +}) + +// 社保计算(使用当前版本或指定月份版本) const calcSchema = z.object({ base: z.number().positive(), + month: z.string().regex(/^\d{4}-\d{2}$/).optional(), }) router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunction) => { try { - const { base } = calcSchema.parse(req.body) - let config = await prisma.socialInsuranceConfig.findUnique({ - where: { orgId: req.user!.orgId }, - }) + const { base, month } = calcSchema.parse(req.body) + const orgId = req.user!.orgId + + let config + if (month) { + config = await prisma.socialInsuranceConfig.findFirst({ + where: { + orgId, + effectiveFrom: { lte: month }, + OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }], + }, + orderBy: { effectiveFrom: 'desc' }, + }) + } if (!config) { - config = await prisma.socialInsuranceConfig.create({ data: { orgId: req.user!.orgId } }) + config = await prisma.socialInsuranceConfig.findFirst({ + where: { orgId, isCurrent: true }, + }) + } + if (!config) { + config = await prisma.socialInsuranceConfig.create({ + data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), createdBy: req.user!.id }, + }) } const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) @@ -93,6 +310,7 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc originalBase: base, capped: base > config.baseMax, floored: base < config.baseMin, + configVersion: config.effectiveFrom, items: [ { name: '养老保险', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: pensionOrg, empAmount: pensionEmp }, { name: '医疗保险', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalOrg, empAmount: medicalEmp }, diff --git a/backend/src/schemas/contract.schema.ts b/backend/src/schemas/contract.schema.ts index 557bd1d..8d06b20 100644 --- a/backend/src/schemas/contract.schema.ts +++ b/backend/src/schemas/contract.schema.ts @@ -29,6 +29,11 @@ export const updateEmployeeSchema = z.object({ monthlySalary: z.string().min(1).optional(), gender: z.enum(['男', '女']).optional(), phone: z.string().regex(/^1[3-9]\d{9}$/).optional(), + bankName: z.string().max(50).optional(), + bankAccount: z.string().max(30).optional(), + emergencyContact: z.string().max(30).optional(), + emergencyPhone: z.string().max(20).optional(), + address: z.string().max(200).optional(), isPregnant: z.boolean().optional(), isInMedicalPeriod: z.boolean().optional(), isWorkInjured: z.boolean().optional(), diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index 40ef95c..3b2c496 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -14,11 +14,12 @@ export function getContractStatus(contract: { hireDate: Date }): { status: string; statusText: string; riskLevel: 'high' | 'medium' | 'low' | 'safe' } { const today = new Date() + const typeLabel = contract.contractType === 'FIXED' ? '固定期限' : contract.contractType === 'UNFIXED' ? '无固定期限' : '' if (!contract.signDate || contract.contractType === 'UNSIGNED') { const days = daysBetween(today, contract.hireDate) if (days > 365) { - return { status: 'unsigned_over_year', statusText: '已视为无固定期限', riskLevel: 'high' } + return { status: 'unsigned_over_year', statusText: '未签合同(已视为无固定期限)', riskLevel: 'high' } } else if (days > 30) { return { status: 'unsigned_over_30', statusText: `未签合同(${days}天)`, riskLevel: 'high' } } @@ -28,14 +29,14 @@ export function getContractStatus(contract: { if (contract.endDate) { const daysToExpire = daysBetween(contract.endDate, today) if (daysToExpire < 0) { - return { status: 'expired', statusText: '已到期未续签', riskLevel: 'high' } + return { status: 'expired', statusText: `${typeLabel}·已到期未续签`, riskLevel: 'high' } } else if (daysToExpire <= 30) { - return { status: 'expiring', statusText: `即将到期(${daysToExpire}天)`, riskLevel: 'medium' } + return { status: 'expiring', statusText: `${typeLabel}·即将到期(${daysToExpire}天)`, riskLevel: 'medium' } } - return { status: 'active', statusText: '正常', riskLevel: 'safe' } + return { status: 'active', statusText: `${typeLabel}·正常`, riskLevel: 'safe' } } - return { status: 'unfixed', statusText: '无固定期限', riskLevel: 'safe' } + return { status: 'unfixed', statusText: '无固定期限·正常', riskLevel: 'safe' } } export function validateProbation(contractMonths: number, probationMonths: number): { valid: boolean; max: number; message?: string } { @@ -233,6 +234,11 @@ export async function updateEmployee(orgId: string, id: string, data: any) { } if (data.gender !== undefined) updateData.gender = data.gender if (data.phone !== undefined) updateData.phone = data.phone + if (data.bankName !== undefined) updateData.bankName = data.bankName + if (data.bankAccount !== undefined) updateData.bankAccount = encrypt(data.bankAccount) + if (data.emergencyContact !== undefined) updateData.emergencyContact = data.emergencyContact + if (data.emergencyPhone !== undefined) updateData.emergencyPhone = data.emergencyPhone + if (data.address !== undefined) updateData.address = data.address if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured @@ -325,6 +331,10 @@ export async function addContract(orgId: string, userId: string, data: any) { contractYears: data.contractYears || 3, probationMonths: data.probationMonths || 0, probationSalary: data.probationSalary || 0, + attachmentName: data.attachmentUrl ? '合同扫描件' : null, + attachmentUrl: data.attachmentUrl || null, + electronicContractNo: data.electronicContractNo || null, + electronicContractUrl: data.electronicContractUrl || null, createdBy: userId, }, }) diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts index bda802b..fe4b767 100644 --- a/backend/src/services/payroll.service.ts +++ b/backend/src/services/payroll.service.ts @@ -115,10 +115,18 @@ export async function calcBatchEntry( month: string, inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number }, batchType: string = 'REGULAR', + options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } }, ) { const [employee, socialConfig] = await Promise.all([ prisma.employee.findFirst({ where: { id: employeeId, orgId } }), - prisma.socialInsuranceConfig.findUnique({ where: { orgId } }), + prisma.socialInsuranceConfig.findFirst({ + where: { + orgId, + effectiveFrom: { lte: month }, + OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }], + }, + orderBy: { effectiveFrom: 'desc' }, + }), ]) if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' } @@ -127,13 +135,25 @@ export async function calcBatchEntry( const housingBase = employee.housingFundBase || inputs.baseSalary let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0 - if (socialConfig) { - const social = calcSocialInsurance(socialBase, socialConfig) - const housing = calcHousingFund(housingBase, socialConfig) - socialEmp = social.socialEmp - socialOrg = social.socialOrg - housingEmp = housing.housingEmp - housingOrg = housing.housingOrg + + // 年终奖/奖金批次:不扣社保公积金 + if (batchType !== 'BONUS' && !options?.skipSocial) { + if (socialConfig) { + const social = calcSocialInsurance(socialBase, socialConfig) + const housing = calcHousingFund(housingBase, socialConfig) + socialEmp = social.socialEmp + socialOrg = social.socialOrg + housingEmp = housing.housingEmp + housingOrg = housing.housingOrg + } + } + + // 手动覆盖社保值 + if (options?.overrideSocial) { + if (options.overrideSocial.socialEmp !== undefined) socialEmp = options.overrideSocial.socialEmp + if (options.overrideSocial.socialOrg !== undefined) socialOrg = options.overrideSocial.socialOrg + if (options.overrideSocial.housingEmp !== undefined) housingEmp = options.overrideSocial.housingEmp + if (options.overrideSocial.housingOrg !== undefined) housingOrg = options.overrideSocial.housingOrg } const totalPay = inputs.baseSalary + inputs.overtimePay + inputs.allowance + inputs.bonus - inputs.deduction diff --git a/backend/src/services/risk.service.ts b/backend/src/services/risk.service.ts index fa8049c..573470c 100644 --- a/backend/src/services/risk.service.ts +++ b/backend/src/services/risk.service.ts @@ -274,7 +274,7 @@ export async function getDashboardData(orgId: string) { where: { orgId, batch: { month: currentMonth, status: 'ARCHIVED' } }, select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, bonus: true, totalPay: true, socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, tax: true, netPay: true, employeeId: true }, }), - prisma.socialInsuranceConfig.findUnique({ where: { orgId } }), + prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }), prisma.laborContract.count({ where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } }, }), diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index d2e1fe7..14648a1 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -15,9 +15,9 @@ export default function Button({ variant = 'primary', size = 'md', className, ch 'bg-primary text-white hover:bg-primary-dark': variant === 'primary', 'bg-gray-100 text-gray-700 hover:bg-gray-200': variant === 'secondary', 'bg-danger text-white hover:bg-red-700': variant === 'danger', - 'px-3 py-1.5 text-sm': size === 'sm', - 'px-4 py-2 text-sm': size === 'md', - 'px-6 py-3 text-base': size === 'lg', + 'px-2.5 py-1 text-xs': size === 'sm', + 'px-3 py-1.5 text-xs': size === 'md', + 'px-5 py-2 text-sm': size === 'lg', }, className, )} diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx index 76210c3..5e548fb 100644 --- a/frontend/src/components/ui/Card.tsx +++ b/frontend/src/components/ui/Card.tsx @@ -3,7 +3,7 @@ import clsx from 'clsx' export default function Card({ className, children, ...props }: HTMLAttributes) { return ( -
+
{children}
) diff --git a/frontend/src/components/ui/EmptyState.tsx b/frontend/src/components/ui/EmptyState.tsx index a588db4..92954c2 100644 --- a/frontend/src/components/ui/EmptyState.tsx +++ b/frontend/src/components/ui/EmptyState.tsx @@ -12,12 +12,12 @@ interface EmptyStateProps { export default function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) { return ( -
-
- {icon || } +
+
+ {icon || }
-

{title}

- {description &&

{description}

} +

{title}

+ {description &&

{description}

} {actionLabel && onAction && ( )} diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx index 575a452..eb500bd 100644 --- a/frontend/src/components/ui/Input.tsx +++ b/frontend/src/components/ui/Input.tsx @@ -7,7 +7,7 @@ export const Input = forwardRef{children} + return } diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index 73bce5f..4f40098 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -29,14 +29,14 @@ export default function Modal({ open, onClose, title, children, className }: Mod
{title && ( -
-

{title}

+
+

{title}

)} -
{children}
+
{children}
) diff --git a/frontend/src/index.css b/frontend/src/index.css index 3b1edfc..baf8698 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -6,16 +6,22 @@ body { @apply bg-surface text-gray-900 antialiased; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 16px; + line-height: 1.5; } * { @apply box-border; } + + h1 { @apply text-base font-semibold; } + h2 { @apply text-sm font-semibold; } + h3 { @apply text-sm font-medium; } } @layer components { .btn { - @apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed; + @apply inline-flex items-center justify-center px-3 py-1.5 rounded font-medium text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed; } .btn-primary { @apply btn bg-primary text-white hover:bg-primary-dark; @@ -27,12 +33,12 @@ @apply btn bg-danger text-white hover:bg-red-700; } .card { - @apply bg-white rounded-lg shadow-sm border border-gray-200 p-4; + @apply bg-white rounded-lg shadow-sm border border-gray-200 p-3; } .input { - @apply w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm; + @apply w-full px-2.5 py-1.5 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs; } .label { - @apply block text-sm font-medium text-gray-700 mb-1; + @apply block text-xs font-medium text-gray-700 mb-0.5; } } diff --git a/frontend/src/pages/AIAssistant.tsx b/frontend/src/pages/AIAssistant.tsx index 8eb58c2..df97c95 100644 --- a/frontend/src/pages/AIAssistant.tsx +++ b/frontend/src/pages/AIAssistant.tsx @@ -31,7 +31,7 @@ export default function AIAssistant() { return (
-

AI 合规顾问

+

AI 合规顾问

{tabs.map((t) => { @@ -40,7 +40,7 @@ export default function AIAssistant() { @@ -167,7 +167,7 @@ function PredictTab() { 分析中...
) : ( -
{result}
+
{result}
)}
@@ -204,7 +204,7 @@ function ReviewTab() {