diff --git a/JClog.png b/JClog.png new file mode 100644 index 0000000..1466611 Binary files /dev/null and b/JClog.png differ diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9be639e..902058e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -263,6 +263,8 @@ model Employee { socialInsEndMonth String? // 当前社保截止年月(便捷字段,null=在保) housingFundStartMonth String? // 当前公积金开始年月(便捷字段) housingFundEndMonth String? // 当前公积金截止年月(便捷字段) + supplementaryHousingAccountId String? // 补充公积金账户(可选,高管才有) + supplementaryHousingAccount SocialAccount? @relation("EmpSupplementaryHousingAccount", fields: [supplementaryHousingAccountId], references: [id], onDelete: SetNull) specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报) city String? // 员工社保参保城市 birthDate DateTime? // 出生日期(从身份证号提取) @@ -489,6 +491,8 @@ model SocialAccount { // 部门关联(员工通过部门继承账户) deptSocialAccounts Department[] @relation("DeptSocialAccount") deptHousingAccounts Department[] @relation("DeptHousingAccount") + // 员工补充公积金关联 + empSupplementaryHousing Employee[] @relation("EmpSupplementaryHousingAccount") @@unique([orgId, type, name]) @@index([orgId, type, city]) @@ -908,6 +912,8 @@ model BatchEntry { socialOrg Float @default(0) housingEmp Float @default(0) housingOrg Float @default(0) + supplementaryHousingEmp Float @default(0) // 补充公积金个人部分 + supplementaryHousingOrg Float @default(0) // 补充公积金单位部分 tax Float @default(0) totalPay Float @default(0) // 应发合计 netPay Float @default(0) // 实发工资 diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index f0d6eb9..ca06c5f 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -467,6 +467,8 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti socialOrg: calcResult.socialOrg, housingEmp: calcResult.housingEmp, housingOrg: calcResult.housingOrg, + supplementaryHousingEmp: calcResult.supplementaryHousingEmp || 0, + supplementaryHousingOrg: calcResult.supplementaryHousingOrg || 0, tax: calcResult.tax, totalPay: calcResult.totalPay, netPay: calcResult.netPay, @@ -578,7 +580,7 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res // 重新计算 const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options) - const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, taxBreakdown, ...entryData } = calcResult + const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, ...entryData } = calcResult const updated = await prisma.batchEntry.update({ where: { id: entry.id }, data: { ...inputs, ...entryData }, @@ -718,10 +720,14 @@ router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthR taxBreakdown.systemSocialOrg = calcResult.systemSocialOrg taxBreakdown.systemHousingEmp = calcResult.systemHousingEmp taxBreakdown.systemHousingOrg = calcResult.systemHousingOrg + taxBreakdown.systemSupplementaryHousingEmp = calcResult.systemSupplementaryHousingEmp + taxBreakdown.systemSupplementaryHousingOrg = calcResult.systemSupplementaryHousingOrg taxBreakdown.actualSocialEmp = entry.socialEmp taxBreakdown.actualSocialOrg = entry.socialOrg taxBreakdown.actualHousingEmp = entry.housingEmp taxBreakdown.actualHousingOrg = entry.housingOrg + taxBreakdown.actualSupplementaryHousingEmp = entry.supplementaryHousingEmp || 0 + taxBreakdown.actualSupplementaryHousingOrg = entry.supplementaryHousingOrg || 0 res.json({ success: true, data: taxBreakdown }) } catch (err) { next(err) @@ -772,7 +778,7 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type) const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId) - const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult + const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, ...entryData } = calcResult const entry = await prisma.batchEntry.create({ data: { batchId, orgId, employeeId, @@ -915,7 +921,7 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response, 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 + const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, ...entryData } = calcResult await prisma.batchEntry.update({ where: { id: entry.id }, data: { ...entryData }, diff --git a/backend/src/schemas/contract.schema.ts b/backend/src/schemas/contract.schema.ts index 8387ded..91ec089 100644 --- a/backend/src/schemas/contract.schema.ts +++ b/backend/src/schemas/contract.schema.ts @@ -15,6 +15,7 @@ export const createEmployeeSchema = z.object({ city: z.string().max(20).optional(), education: z.string().max(20).optional(), position: z.string().max(50).optional(), + supplementaryHousingAccountId: z.string().nullable().optional(), contract: z.object({ signDate: z.string().datetime().nullable(), startDate: z.string().datetime(), @@ -51,6 +52,7 @@ export const updateEmployeeSchema = z.object({ education: z.string().max(20).optional(), position: z.string().max(50).optional(), status: z.enum(['ACTIVE', 'PENDING_ONBOARD', 'RESIGNED', 'BLACKLISTED']).optional(), + supplementaryHousingAccountId: z.string().nullable().optional(), cityChangeReason: z.string().max(200).optional(), }) diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index f5464cd..bf5bae8 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -407,6 +407,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) { education: data.education || null, position: data.position || null, status: data.status || 'ACTIVE', + supplementaryHousingAccountId: data.supplementaryHousingAccountId || null, }, }) @@ -759,6 +760,7 @@ export async function updateEmployee(orgId: string, id: string, data: any) { if (data.education !== undefined) updateData.education = data.education if (data.position !== undefined) updateData.position = data.position if (data.status !== undefined) updateData.status = data.status + if (data.supplementaryHousingAccountId !== undefined) updateData.supplementaryHousingAccountId = data.supplementaryHousingAccountId || null // 参保城市变更:关闭旧城市在保记录,创建新城市记录 if (data.city !== undefined && data.city !== employee.city) { diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts index 4aa8f9f..e826d7d 100644 --- a/backend/src/services/payroll.service.ts +++ b/backend/src/services/payroll.service.ts @@ -220,6 +220,7 @@ export async function calcBatchEntry( const { socialAccount, housingAccount } = await getEmployeeAccounts(orgId, employeeId) let socialConfig: any = null let housingConfig: any = null + let supplementaryHousingConfig: any = null if (socialAccount) { socialConfig = await getStandardByAccountAndMonth(socialAccount.id, month) @@ -227,6 +228,10 @@ export async function calcBatchEntry( if (housingAccount) { housingConfig = await getStandardByAccountAndMonth(housingAccount.id, month) } + // 补充公积金:员工单独关联的补充公积金账户 + if (employee.supplementaryHousingAccountId) { + supplementaryHousingConfig = await getStandardByAccountAndMonth(employee.supplementaryHousingAccountId, month) + } // 回退到旧配置表(兼容未迁移数据) const cityWhere = employee.city ? { orgId, city: employee.city } : { orgId } @@ -248,12 +253,14 @@ export async function calcBatchEntry( const housingBase = isNoSocialContract ? 0 : (employee.housingFundBase != null ? employee.housingFundBase : inputs.baseSalary) let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0 + let suppHousingEmp = 0, suppHousingOrg = 0 // 年终奖/奖金批次、补偿金批次、劳务协议/实习协议人员:不扣社保公积金 // 其他批次:计算当月应缴全额,减去已归档批次已扣金额,差额为本批次应扣 if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial && !isNoSocialContract) { // 1. 计算当月应缴社保公积金全额 let fullSocialEmp = 0, fullSocialOrg = 0, fullHousingEmp = 0, fullHousingOrg = 0 + let fullSuppHousingEmp = 0, fullSuppHousingOrg = 0 if (socialConfig) { const social = calcSocialInsurance(socialBase, socialConfig) fullSocialEmp = social.socialEmp @@ -264,6 +271,12 @@ export async function calcBatchEntry( fullHousingEmp = housing.housingEmp fullHousingOrg = housing.housingOrg } + // 补充公积金(用同样的公积金基数) + if (supplementaryHousingConfig) { + const suppHousing = calcHousingFund(housingBase, supplementaryHousingConfig) + fullSuppHousingEmp = suppHousing.housingEmp + fullSuppHousingOrg = suppHousing.housingOrg + } // 2. 查询该员工当月已归档批次中已扣的社保公积金金额 const archivedEntries = await prisma.batchEntry.findMany({ @@ -272,18 +285,22 @@ export async function calcBatchEntry( employeeId, batch: { month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } }, }, - select: { socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true }, + select: { socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, supplementaryHousingEmp: true, supplementaryHousingOrg: true }, }) const deductedSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) const deductedSocialOrg = archivedEntries.reduce((s, e) => s + e.socialOrg, 0) const deductedHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) const deductedHousingOrg = archivedEntries.reduce((s, e) => s + e.housingOrg, 0) + const deductedSuppHousingEmp = archivedEntries.reduce((s, e) => s + (e.supplementaryHousingEmp || 0), 0) + const deductedSuppHousingOrg = archivedEntries.reduce((s, e) => s + (e.supplementaryHousingOrg || 0), 0) // 3. 本批次应扣 = 应缴全额 - 已扣金额(不足额补扣,足额为0) socialEmp = Math.max(0, fullSocialEmp - deductedSocialEmp) socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg) housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp) housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg) + suppHousingEmp = Math.max(0, fullSuppHousingEmp - deductedSuppHousingEmp) + suppHousingOrg = Math.max(0, fullSuppHousingOrg - deductedSuppHousingOrg) // 4. 叠加上月递延的社保/公积金(入职当月未扣完的部分,本月补扣) if (options?.prevDeferred) { @@ -297,6 +314,8 @@ export async function calcBatchEntry( const systemSocialOrg = socialOrg const systemHousingEmp = housingEmp const systemHousingOrg = housingOrg + const systemSupplementaryHousingEmp = suppHousingEmp + const systemSupplementaryHousingOrg = suppHousingOrg // 手动覆盖社保值 if (options?.overrideSocial) { @@ -340,11 +359,11 @@ export async function calcBatchEntry( status: 'ARCHIVED', }, }, - select: { totalPay: true, socialEmp: true, housingEmp: true, tax: true }, + select: { totalPay: true, socialEmp: true, housingEmp: true, supplementaryHousingEmp: true, tax: true }, }) const ytdIncome = archivedEntries.reduce((s, e) => s + e.totalPay, 0) + totalPay const ytdSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) + socialEmp - const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) + housingEmp + const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp + (e.supplementaryHousingEmp || 0), 0) + housingEmp + suppHousingEmp // 专项附加扣除:按月读取 SpecialDeductionRecord 实际填报金额累加 // 优先使用按月记录;若无按月记录,回退到员工便捷字段 × 月数(兼容旧数据) @@ -392,8 +411,8 @@ export async function calcBatchEntry( // 上月递延的最低工资补齐差额(本批次需扣回) const prevDeferredMinWage = options?.prevDeferred?.minWage || 0 - // 初始实发 = 应发 - 社保 - 公积金 - 个税 - 上月递延最低工资补扣 - let netPay = totalPay - socialEmp - housingEmp - tax - prevDeferredMinWage + // 初始实发 = 应发 - 社保 - 公积金 - 补充公积金 - 个税 - 上月递延最低工资补扣 + let netPay = totalPay - socialEmp - housingEmp - suppHousingEmp - tax - prevDeferredMinWage // 递延金额(本批次扣不动、递延到次月的部分) let deferredSocialEmp = 0 @@ -433,21 +452,31 @@ export async function calcBatchEntry( socialEmp -= shortfall netPay = targetNetPay minWageApplied = shortfall - } else if (shortfall <= socialEmp + housingEmp) { - // 递延全部社保 + 部分公积金 + } else if (shortfall <= socialEmp + housingEmp + suppHousingEmp) { + // 递延全部社保 + 部分公积金(含补充) deferredSocialEmp = socialEmp - deferredHousingEmp = shortfall - socialEmp socialEmp = 0 - housingEmp -= deferredHousingEmp + const housingShortfall = shortfall - deferredSocialEmp + // 先递延基本公积金,再递延补充公积金 + if (housingShortfall <= housingEmp) { + deferredHousingEmp = housingShortfall + housingEmp -= housingShortfall + } else { + deferredHousingEmp = housingEmp + const suppShortfall = housingShortfall - housingEmp + housingEmp = 0 + suppHousingEmp -= suppShortfall + } netPay = targetNetPay minWageApplied = shortfall } else { - // 递延全部社保 + 全部公积金,仍不足 → 差额作为最低工资补齐递延 + // 递延全部社保 + 全部公积金(含补充),仍不足 → 差额作为最低工资补齐递延 deferredSocialEmp = socialEmp deferredHousingEmp = housingEmp - const remainingShortfall = shortfall - socialEmp - housingEmp + const remainingShortfall = shortfall - socialEmp - housingEmp - suppHousingEmp socialEmp = 0 housingEmp = 0 + suppHousingEmp = 0 deferredMinWage = remainingShortfall netPay = targetNetPay minWageApplied = shortfall @@ -461,10 +490,14 @@ export async function calcBatchEntry( socialOrg: Math.round(socialOrg * 100) / 100, housingEmp: Math.round(housingEmp * 100) / 100, housingOrg: Math.round(housingOrg * 100) / 100, + supplementaryHousingEmp: Math.round(suppHousingEmp * 100) / 100, + supplementaryHousingOrg: Math.round(suppHousingOrg * 100) / 100, systemSocialEmp: Math.round(systemSocialEmp * 100) / 100, systemSocialOrg: Math.round(systemSocialOrg * 100) / 100, systemHousingEmp: Math.round(systemHousingEmp * 100) / 100, systemHousingOrg: Math.round(systemHousingOrg * 100) / 100, + systemSupplementaryHousingEmp: Math.round(systemSupplementaryHousingEmp * 100) / 100, + systemSupplementaryHousingOrg: Math.round(systemSupplementaryHousingOrg * 100) / 100, tax, taxBreakdown, totalPay: Math.round(totalPay * 100) / 100, diff --git a/frontend/src/pages/roster/modals.tsx b/frontend/src/pages/roster/modals.tsx index a790b9e..f232bfc 100644 --- a/frontend/src/pages/roster/modals.tsx +++ b/frontend/src/pages/roster/modals.tsx @@ -732,6 +732,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: { const [autoAccounts, setAutoAccounts] = useState<{ socialAccount: any; housingAccount: any; socialStandard: any; housingStandard: any } | null>(null) const [manualSocialAccountId, setManualSocialAccountId] = useState('') const [manualHousingAccountId, setManualHousingAccountId] = useState('') + const [supplementaryHousingAccountId, setSupplementaryHousingAccountId] = useState('') // 部门变化时查询适用账户 useEffect(() => { @@ -757,6 +758,8 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: { queryFn: () => socialAccountApi.list('HOUSING'), enabled: !!form.departmentId, }) + // 补充公积金账户(accountType=SUPPLEMENTARY) + const supplementaryHousingAccounts = allHousingAccounts.filter((a: any) => a.accountType === 'SUPPLEMENTARY') // 入职日期变更 → 同步合同开始日期 + 重算结束日期 const handleHireDateChange = (hireDate: string) => { @@ -952,6 +955,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: { housingFundStartMonth: form.housingFundStartMonth || undefined, socialAccountId: manualSocialAccountId || undefined, housingAccountId: manualHousingAccountId || undefined, + supplementaryHousingAccountId: supplementaryHousingAccountId || undefined, } if (form.contractType !== 'UNSIGNED' && form.startDate) { data.contract = { @@ -1050,6 +1054,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: { {/* 账户选择(选部门后自动带出,可手动调整) */} {!isNoSocialContract && ( + <>
@@ -1080,6 +1085,20 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: { )}
+ {supplementaryHousingAccounts.length > 0 && ( +
+
+ + +
+
+ )} + )}