refactor: 社保公积金Tab重构为账户卡片列表+展开年度标准管理

1. 社保/公积金Tab从"选账户→展示配置"改为"账户卡片列表+展开管理"
2. 每个账户卡片可展开显示:当前标准/最低工资编辑/新建年度标准/版本历史/调基/试算
3. 账户新建/编辑/删除/设默认从设置页面迁移到社保公积金菜单
4. 设置页面去掉"社保公积金账户"Tab
5. "新建版本"改名为"新建年度标准"
6. 新增AccountCard组件独立管理每个账户的展开状态和数据查询

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-16 15:05:06 +08:00
parent a5911d1874
commit 8391c123fc
10 changed files with 985 additions and 849 deletions
+3 -1
View File
@@ -24,6 +24,7 @@ enum Role {
}
enum EmployeeStatus {
PRE_ONBOARD // 预入职:已录入但未正式入职,不进入薪资批次
ACTIVE
RESIGNED
TERMINATED
@@ -848,7 +849,8 @@ model PayrollBatch {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
month String // YYYY-MM
month String // 所属月(计薪月)YYYY-MM,决定调用哪个月的社保标准、个税累计
payMonth String? // 发薪年月 YYYY-MM(实际发放月份,如十一提前发薪时=9月,所属月=10月)。null=与所属月相同
batchNo Int // 批次序号(1, 2, 3...
name String // 批次名称
type PayrollBatchType @default(REGULAR)
+5 -1
View File
@@ -254,6 +254,7 @@ router.put('/batches/:id/name', async (req: AuthRequest, res: Response, next: Ne
// 创建批次
const createBatchSchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/),
payMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(),
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).default('REGULAR'),
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch', 'custom']).default('copy_last'),
sourceBatchId: z.string().optional(),
@@ -264,7 +265,7 @@ const createBatchSchema = z.object({
router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, type, mode, sourceBatchId, employeeIds, name, remark } = createBatchSchema.parse(req.body)
const { month, payMonth, type, mode, sourceBatchId, employeeIds, name, remark } = createBatchSchema.parse(req.body)
const orgId = req.user!.orgId
// 查询当月最大批次号,避免删除后 count 不准导致唯一键冲突
@@ -339,6 +340,8 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
{ status: 'ACTIVE' },
{ status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } },
],
// 预入职员工不进入薪资批次
NOT: { status: 'PRE_ONBOARD' },
},
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
@@ -350,6 +353,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
data: {
orgId,
month,
payMonth: payMonth || null,
batchNo,
name: batchName,
type,
+25 -3
View File
@@ -100,12 +100,18 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
// 状态过滤在 DB 层完成(contractStatus 需要后处理计算,仍需内存过滤)
if (status === 'RESIGNED') {
whereBase.status = 'RESIGNED'
} else if (status === 'PRE_HIRE') {
whereBase.status = 'ACTIVE'
whereBase.hireDate = { gt: todayEnd }
} else if (status === 'PRE_HIRE' || status === 'PRE_ONBOARD') {
// 预入职:PRE_ONBOARD 状态,或 ACTIVE 状态但入职日期在未来
whereBase.OR = [
{ status: 'PRE_ONBOARD' },
{ status: 'ACTIVE', hireDate: { gt: todayEnd } },
]
} else if (status === 'ACTIVE') {
whereBase.status = 'ACTIVE'
whereBase.hireDate = { lte: todayEnd }
} else if (!status) {
// 无状态过滤时,排除 PRE_ONBOARD(默认只看在职和离职)
whereBase.NOT = { status: 'PRE_ONBOARD' }
}
// unsigned 合同状态可以在 DB 层过滤
@@ -1600,4 +1606,20 @@ router.get('/contract-types', authMiddleware, (_req: AuthRequest, res) => {
res.json({ success: true, data: types })
})
// 预入职转正式(PRE_ONBOARD → ACTIVE
router.post('/:id/activate', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const emp = await prisma.employee.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId! } })
if (!emp) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
if (emp.status !== 'PRE_ONBOARD') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '该员工不是预入职状态' } })
}
const updated = await prisma.employee.update({
where: { id: emp.id },
data: { status: 'ACTIVE' },
})
res.json({ success: true, data: { id: updated.id, status: updated.status } })
} catch (err) { next(err) }
})
export default router
+1
View File
@@ -401,6 +401,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
city: data.city || '北京',
education: data.education || null,
position: data.position || null,
status: data.status || 'ACTIVE',
},
})
+51 -30
View File
@@ -391,37 +391,58 @@ export async function calcBatchEntry(
let deferredMinWage = 0
let minWageApplied = 0 // 当月实际补齐到最低工资的金额
// 最低工资保护:实发不得低于最低工资标准(仅 REGULAR / TERMINATION 批次)
if (minWage > 0 && batchType !== 'BONUS' && batchType !== 'SEVERANCE' && netPay < minWage) {
const shortfall = minWage - netPay // 需要补齐的金额
// 最低工资保护:当月累计实发不得低于最低工资标准(仅 REGULAR / TERMINATION 批次)
// 第二批次时需判断"当月累计实发"(已归档批次 netPay 之和 + 本批次 netPay)是否低于 minWage
if (minWage > 0 && batchType !== 'BONUS' && batchType !== 'SEVERANCE') {
// 查当月已归档批次的累计实发(同月已归档的 REGULAR/TERMINATION 批次)
const archivedNetPayEntries = await prisma.batchEntry.findMany({
where: {
orgId,
employeeId,
batch: { month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
},
select: { netPay: true },
})
const archivedNetPay = archivedNetPayEntries.reduce((s, e) => s + e.netPay, 0)
// 优先递延社保个人部分(减少当月社保扣款)
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
// 当月累计实发 = 已归档批次实发 + 本批次实发
const monthlyCumulativeNetPay = archivedNetPay + netPay
// 仅当当月累计实发低于最低工资时才触发保护
if (monthlyCumulativeNetPay < minWage) {
// 需要补齐的金额 = 最低工资 - 当月累计实发
// 但本批次最多补齐到本批次实发为正,且不超过 minWage - archivedNetPay
const targetNetPay = Math.max(0, minWage - archivedNetPay)
const shortfall = targetNetPay - netPay // 需要补齐的金额(正数表示需要补齐)
if (shortfall > 0) {
// 优先递延社保个人部分(减少当月社保扣款)
if (shortfall <= socialEmp) {
// 只递延社保就够了
deferredSocialEmp = shortfall
socialEmp -= shortfall
netPay = targetNetPay
minWageApplied = shortfall
} else if (shortfall <= socialEmp + housingEmp) {
// 递延全部社保 + 部分公积金
deferredSocialEmp = socialEmp
deferredHousingEmp = shortfall - socialEmp
socialEmp = 0
housingEmp -= deferredHousingEmp
netPay = targetNetPay
minWageApplied = shortfall
} else {
// 递延全部社保 + 全部公积金,仍不足 → 差额作为最低工资补齐递延
deferredSocialEmp = socialEmp
deferredHousingEmp = housingEmp
const remainingShortfall = shortfall - socialEmp - housingEmp
socialEmp = 0
housingEmp = 0
deferredMinWage = remainingShortfall
netPay = targetNetPay
minWageApplied = shortfall
}
}
}
}