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({ @@ -520,6 +521,9 @@ export default function SocialInsurance() {

失业(企业/个人){activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%
工伤(企业){activeConfig.injuryOrg}%
生育(企业){activeConfig.maternityOrg}%
+ {activeConfig.minWage > 0 && ( +
最低工资标准¥{fmt(activeConfig.minWage)}
+ )} {Array.isArray(activeConfig.extraInsurances) && activeConfig.extraInsurances.map((ins: any, idx: number) => (
{ins.name}(企业/个人) @@ -723,6 +727,15 @@ export default function SocialInsurance() {
)} + {!isHousing && ( +
+
+ + activeSetNewVersion({ ...activeNewVersion, minWage: Number(e.target.value) })} placeholder="如 2420" /> +

当地月最低工资标准,填 0 不检查。实发低于此值时触发保护(递延扣款)

+
+
+ )} {isHousing ? ( <>
diff --git a/frontend/src/pages/money/BatchTab.tsx b/frontend/src/pages/money/BatchTab.tsx index ac0a7d3..aed08f9 100644 --- a/frontend/src/pages/money/BatchTab.tsx +++ b/frontend/src/pages/money/BatchTab.tsx @@ -964,6 +964,21 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void 请检查社保/公积金基数是否过大,导致实发工资 ≤ 0 的记录需修正后再归档。 )} + {batch.entries.some((e: any) => e.minWage > 0 && e.netPay < e.minWage && (e.minWageApplied || 0) === 0) && ( + + 部分员工实发工资低于当地最低工资标准 ¥{Math.min(...batch.entries.filter((e: any) => e.minWage > 0 && e.netPay < e.minWage).map((e: any) => e.minWage))},请检查社保基数或在社保配置中设置最低工资标准。 + + )} + {batch.entries.some((e: any) => (e.minWageApplied || 0) > 0) && ( + + {batch.entries.filter((e: any) => (e.minWageApplied || 0) > 0).length} 名员工已补齐到最低工资标准,递延扣款(社保/公积金/补齐差额)将在次月工资中补扣。 + + )} + {batch.entries.some((e: any) => (e.prevDeferredSocialEmp || 0) + (e.prevDeferredHousingEmp || 0) + (e.prevDeferredMinWage || 0) > 0) && ( + + {batch.entries.filter((e: any) => (e.prevDeferredSocialEmp || 0) + (e.prevDeferredHousingEmp || 0) + (e.prevDeferredMinWage || 0) > 0).length} 名员工本月补扣了上月递延的社保/公积金/最低工资补齐差额。 + + )} {batch.entries.some((e: any) => e.baseSalary === 0 && e.bonus === 0 && e.totalPay === 0) && ( 部分员工所有金额为 0,请确认是否需要填写或移除这些人员。