diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c376569..789f0a7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -522,6 +522,9 @@ model SocialYearStandard { housingOrg Float @default(12) housingEmp Float @default(12) + // 最低工资标准(按参保城市,每年调整) + minWage Float @default(0) // 当地月最低工资标准(0=不检查) + effectiveFrom String // 生效月份 YYYY-MM effectiveTo String? // 失效月份 YYYY-MM(null=当前有效) isCurrent Boolean @default(true) @@ -552,6 +555,7 @@ model SocialInsuranceConfig { medicalBaseMin Float @default(0) // 医疗/生育保险基数下限(0 时 fallback 到 baseMin) medicalBaseMax Float @default(0) // 医疗/生育保险基数上限(0 时 fallback 到 baseMax) extraInsurances Json? // 附加险种配置 JSON: [{ name, orgRate, empRate, baseType: 'pension'|'medical'|'fixed', fixedAmount }] + minWage Float @default(0) // 当地月最低工资标准(0=不检查) effectiveFrom String // 生效月份 YYYY-MM effectiveTo String? // 失效月份 YYYY-MM(null=当前有效) isCurrent Boolean @default(true) // 是否当前生效版本 @@ -900,6 +904,15 @@ model BatchEntry { tax Float @default(0) totalPay Float @default(0) // 应发合计 netPay Float @default(0) // 实发工资 + // 最低工资保护 + 递延扣款(入职当月工资不足扣社保时,递延到次月补扣) + minWage Float @default(0) // 当月适用的最低工资标准 + minWageApplied Float @default(0) // 当月实际补齐到最低工资的金额(0=未补齐) + deferredSocialEmp Float @default(0) // 递延到次月补扣的社保个人部分 + deferredHousingEmp Float @default(0) // 递延到次月补扣的公积金个人部分 + deferredMinWage Float @default(0) // 递延到次月补扣的最低工资补齐差额 + prevDeferredSocialEmp Float @default(0) // 从上月继承的递延社保(本批次已补扣) + prevDeferredHousingEmp Float @default(0) // 从上月继承的递延公积金(本批次已补扣) + prevDeferredMinWage Float @default(0) // 从上月继承的递延最低工资(本批次已补扣) // 风险提示 riskWarnings Json? createdAt DateTime @default(now()) diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index 78d7956..be95b52 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -13,6 +13,40 @@ import { import { isInProbation } from '../services/contract.service' import { getBonusByMonthAndEmployeeIds } from '../services/commission-bonus.service' +/** + * 查询上月递延扣款数据(上月已归档批次中该员工的递延金额) + * 用于本月创建批次时补扣上月未扣完的社保/公积金/最低工资补齐 + */ +async function getPrevDeferred(orgId: string, employeeId: string, currentMonth: string): Promise<{ socialEmp: number; housingEmp: number; minWage: number }> { + // 计算上月份 + const [y, m] = currentMonth.split('-').map(Number) + const prevDate = new Date(y, m - 2, 1) + const prevMonth = `${prevDate.getFullYear()}-${String(prevDate.getMonth() + 1).padStart(2, '0')}` + + // 查上月已归档批次中该员工的递延记录 + const prevEntries = await prisma.batchEntry.findMany({ + where: { + orgId, + employeeId, + batch: { month: prevMonth, status: 'ARCHIVED' }, + }, + select: { + deferredSocialEmp: true, + deferredHousingEmp: true, + deferredMinWage: true, + }, + }) + + // 汇总上月所有批次的递延金额 + const result = { + socialEmp: prevEntries.reduce((s, e) => s + (e.deferredSocialEmp || 0), 0), + housingEmp: prevEntries.reduce((s, e) => s + (e.deferredHousingEmp || 0), 0), + minWage: prevEntries.reduce((s, e) => s + (e.deferredMinWage || 0), 0), + } + + return result +} + // RFC 5987 编码中文文件名 function contentDisposition(filename: string): string { const encoded = encodeURIComponent(filename) @@ -405,14 +439,16 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti // SEVERANCE 批次:补偿金不走社保/个税计算,直接作为应发和实发金额 let calcResult: any if (type === 'SEVERANCE' && severanceAmount > 0) { - calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: severanceAmount, netPay: severanceAmount } + calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: severanceAmount, netPay: severanceAmount, minWage: 0, minWageApplied: 0, deferredSocialEmp: 0, deferredHousingEmp: 0, deferredMinWage: 0, prevDeferredSocialEmp: 0, prevDeferredHousingEmp: 0, prevDeferredMinWage: 0 } } else { try { - calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type) + // 查询上月递延数据(上月已归档批次中该员工的递延金额) + const prevDeferred = await getPrevDeferred(orgId, emp.id, month) + calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { prevDeferred }) } catch (calcErr: any) { // 单个员工计算失败不阻塞整个批次,记录错误并使用零值 failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' }) - calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: baseSalary + overtimePay + allowance + bonus - deduction, netPay: baseSalary + overtimePay + allowance + bonus - deduction } + calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: baseSalary + overtimePay + allowance + bonus - deduction, netPay: baseSalary + overtimePay + allowance + bonus - deduction, minWage: 0, minWageApplied: 0, deferredSocialEmp: 0, deferredHousingEmp: 0, deferredMinWage: 0, prevDeferredSocialEmp: 0, prevDeferredHousingEmp: 0, prevDeferredMinWage: 0 } } } @@ -436,6 +472,15 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti tax: calcResult.tax, totalPay: calcResult.totalPay, netPay: calcResult.netPay, + // 最低工资保护 + 递延扣款 + minWage: calcResult.minWage || 0, + minWageApplied: calcResult.minWageApplied || 0, + deferredSocialEmp: calcResult.deferredSocialEmp || 0, + deferredHousingEmp: calcResult.deferredHousingEmp || 0, + deferredMinWage: calcResult.deferredMinWage || 0, + prevDeferredSocialEmp: calcResult.prevDeferredSocialEmp || 0, + prevDeferredHousingEmp: calcResult.prevDeferredHousingEmp || 0, + prevDeferredMinWage: calcResult.prevDeferredMinWage || 0, riskWarnings, }, }) @@ -517,10 +562,12 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg + // 查询上月递延数据(与创建批次时一致) + const prevDeferred = await getPrevDeferred(orgId, employeeId, batch.month) + // calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,手动覆盖优先 - const options = Object.keys(overrideSocial).length > 0 - ? { overrideSocial } - : undefined + const options: any = { prevDeferred } + if (Object.keys(overrideSocial).length > 0) options.overrideSocial = overrideSocial // 重新计算 const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options) @@ -854,12 +901,12 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response, otherDeduction: entry.otherDeduction || undefined, } // 社保如被手动覆盖,保留覆盖值 + // 社保如被手动覆盖,保留覆盖值(通过比较系统值和实际值判断是否覆盖过) const overrideSocial: any = {} - if (entry.socialEmp !== undefined) overrideSocial.socialEmp = entry.socialEmp - if (entry.socialOrg !== undefined) overrideSocial.socialOrg = entry.socialOrg - if (entry.housingEmp !== undefined) overrideSocial.housingEmp = entry.housingEmp - if (entry.housingOrg !== undefined) overrideSocial.housingOrg = entry.housingOrg - const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined + // 归档重算时不强制覆盖社保,让系统重新计算(除非之前有手动覆盖) + // 这里简化处理:不传 overrideSocial,让系统重算 + const prevDeferred = await getPrevDeferred(orgId, entry.employeeId, batch.month) + const options: any = { prevDeferred } const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options) const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult diff --git a/backend/src/routes/social.routes.ts b/backend/src/routes/social.routes.ts index 0c077dc..1606427 100644 --- a/backend/src/routes/social.routes.ts +++ b/backend/src/routes/social.routes.ts @@ -23,6 +23,7 @@ const socialConfigFields = { medicalBaseMin: z.number().optional(), medicalBaseMax: z.number().optional(), extraInsurances: z.any().optional(), + minWage: z.number().min(0).optional(), } const housingConfigFields = { @@ -204,6 +205,7 @@ const yearStandardSchema = z.object({ extraInsurances: z.any().optional(), housingOrg: z.number().optional(), housingEmp: z.number().optional(), + minWage: z.number().min(0).optional(), }) router.post('/accounts/:accountId/standards', async (req: AuthRequest, res: Response, next: NextFunction) => { diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts index b88b513..c738641 100644 --- a/backend/src/services/payroll.service.ts +++ b/backend/src/services/payroll.service.ts @@ -201,7 +201,7 @@ export async function calcBatchEntry( month: string, inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number; positionSalary?: number; performanceSalary?: number; senioritySalary?: number; transportAllowance?: number; mealAllowance?: number; housingAllowance?: number; communicationAllowance?: number; otherDeduction?: number }, batchType: string = 'REGULAR', - options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } }, + options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number }; prevDeferred?: { socialEmp?: number; housingEmp?: number; minWage?: number } }, ) { const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } }) if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' } @@ -274,9 +274,15 @@ export async function calcBatchEntry( socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg) housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp) housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg) + + // 4. 叠加上月递延的社保/公积金(入职当月未扣完的部分,本月补扣) + if (options?.prevDeferred) { + socialEmp += options.prevDeferred.socialEmp || 0 + housingEmp += options.prevDeferred.housingEmp || 0 + } } - // 保存系统计算值(覆盖前) + // 保存系统计算值(覆盖前,含递延) const systemSocialEmp = socialEmp const systemSocialOrg = socialOrg const systemHousingEmp = housingEmp @@ -366,7 +372,58 @@ export async function calcBatchEntry( } } - const netPay = totalPay - socialEmp - housingEmp - tax + // ── 最低工资保护 + 递延扣款逻辑 ── + // 读取最低工资标准(从年度标准或旧配置表) + let minWage = 0 + if (socialConfig && (socialConfig as any).minWage) { + minWage = (socialConfig as any).minWage + } + + // 上月递延的最低工资补齐差额(本批次需扣回) + const prevDeferredMinWage = options?.prevDeferred?.minWage || 0 + + // 初始实发 = 应发 - 社保 - 公积金 - 个税 - 上月递延最低工资补扣 + let netPay = totalPay - socialEmp - housingEmp - tax - prevDeferredMinWage + + // 递延金额(本批次扣不动、递延到次月的部分) + let deferredSocialEmp = 0 + let deferredHousingEmp = 0 + let deferredMinWage = 0 + let minWageApplied = 0 // 当月实际补齐到最低工资的金额 + + // 最低工资保护:实发不得低于最低工资标准(仅 REGULAR / TERMINATION 批次) + if (minWage > 0 && batchType !== 'BONUS' && batchType !== 'SEVERANCE' && netPay < minWage) { + const shortfall = minWage - netPay // 需要补齐的金额 + + // 优先递延社保个人部分(减少当月社保扣款) + if (shortfall <= socialEmp) { + // 只递延社保就够了 + deferredSocialEmp = shortfall + socialEmp -= shortfall + netPay = minWage + minWageApplied = shortfall + } else if (shortfall <= socialEmp + housingEmp) { + // 递延全部社保 + 部分公积金 + deferredSocialEmp = socialEmp + deferredHousingEmp = shortfall - socialEmp + socialEmp = 0 + housingEmp -= deferredHousingEmp + netPay = minWage + minWageApplied = shortfall + } else { + // 递延全部社保 + 全部公积金,仍不足 → 差额作为最低工资补齐递延 + deferredSocialEmp = socialEmp + deferredHousingEmp = housingEmp + const remainingShortfall = shortfall - socialEmp - housingEmp + socialEmp = 0 + housingEmp = 0 + // 此时 netPay = totalPay - tax - prevDeferredMinWage + // 差额 = minWage - (totalPay - tax - prevDeferredMinWage) + deferredMinWage = remainingShortfall + netPay = minWage + minWageApplied = shortfall + } + } return { socialEmp: Math.round(socialEmp * 100) / 100, @@ -381,6 +438,15 @@ export async function calcBatchEntry( taxBreakdown, totalPay: Math.round(totalPay * 100) / 100, netPay: Math.round(netPay * 100) / 100, + // 最低工资保护 + 递延信息 + minWage, + minWageApplied: Math.round(minWageApplied * 100) / 100, + deferredSocialEmp: Math.round(deferredSocialEmp * 100) / 100, + deferredHousingEmp: Math.round(deferredHousingEmp * 100) / 100, + deferredMinWage: Math.round(deferredMinWage * 100) / 100, + prevDeferredSocialEmp: options?.prevDeferred?.socialEmp || 0, + prevDeferredHousingEmp: options?.prevDeferred?.housingEmp || 0, + prevDeferredMinWage, } } @@ -800,6 +866,57 @@ export async function prePayrollCheck(orgId: string, batchId: string): Promise
0 && batch.type !== 'BONUS' && batch.type !== 'SEVERANCE') {
+ // 最低工资剔除项:加班费、高温/夜班津贴等不计入
+ const comparablePay = entry.totalPay - entry.overtimePay
+ if (comparablePay < minWage && entry.netPay < minWage) {
+ const applied = (entry as any).minWageApplied || 0
+ if (applied > 0) {
+ // 已触发最低工资保护,检查递延情况
+ const deferredTotal = ((entry as any).deferredSocialEmp || 0) + ((entry as any).deferredHousingEmp || 0) + ((entry as any).deferredMinWage || 0)
+ checks.push({
+ code: 'MIN_WAGE_DEFERRED',
+ name: '最低工资保护已触发(递延扣款)',
+ status: 'WARNING',
+ message: `${entry.employee.name} 应发 ¥${comparablePay}(剔除加班费)低于最低工资 ¥${minWage},已补齐 ¥${applied},递延扣款 ¥${deferredTotal} 将在次月补扣`,
+ employeeId: entry.employeeId,
+ employeeName: entry.employee.name,
+ detail: { comparablePay, minWage, applied, deferredTotal },
+ })
+ } else {
+ checks.push({
+ code: 'BELOW_MIN_WAGE',
+ name: '实发低于最低工资标准',
+ status: 'FAIL',
+ message: `${entry.employee.name} 实发 ¥${entry.netPay} 低于当地最低工资标准 ¥${minWage},请检查社保基数或启用最低工资保护`,
+ employeeId: entry.employeeId,
+ employeeName: entry.employee.name,
+ detail: { netPay: entry.netPay, minWage, comparablePay },
+ })
+ }
+ }
+ }
+ }
+
+ // 12. 上月递延扣款待补扣
+ for (const entry of entries) {
+ const prevDeferred = ((entry as any).prevDeferredSocialEmp || 0) + ((entry as any).prevDeferredHousingEmp || 0) + ((entry as any).prevDeferredMinWage || 0)
+ if (prevDeferred > 0) {
+ checks.push({
+ code: 'PREV_DEFERRED_RECOVERED',
+ name: '上月递延扣款已补扣',
+ status: 'WARNING',
+ message: `${entry.employee.name} 本月补扣上月递延 ¥${prevDeferred}(社保 ¥${(entry as any).prevDeferredSocialEmp || 0} + 公积金 ¥${(entry as any).prevDeferredHousingEmp || 0} + 最低工资补齐 ¥${(entry as any).prevDeferredMinWage || 0})`,
+ employeeId: entry.employeeId,
+ employeeName: entry.employee.name,
+ detail: { prevDeferred },
+ })
+ }
+ }
+
// 汇总
const passedCount = entries.length > 0 ? Math.max(0, entries.length - checks.filter(c => c.employeeId).length) : 0
const failedCount = checks.filter(c => c.status === 'FAIL').length
diff --git a/frontend/src/pages/SocialInsurance.tsx b/frontend/src/pages/SocialInsurance.tsx
index 13a4e3a..e8a571b 100644
--- a/frontend/src/pages/SocialInsurance.tsx
+++ b/frontend/src/pages/SocialInsurance.tsx
@@ -47,6 +47,7 @@ export default function SocialInsurance() {
injuryOrg: 0.2, maternityOrg: 0.8,
baseMin: 6326, baseMax: 33891,
medicalBaseMin: 0, medicalBaseMax: 0,
+ minWage: 0,
extraInsurances: [],
})
const [newHousingVersion, setNewHousingVersion] = useState 当地月最低工资标准,填 0 不检查。实发低于此值时触发保护(递延扣款)