fix: blank_employees 模式不再自动带出基本工资

本月空白模式应所有金额默认 0,去掉从 emp.monthlySalary 自动填充
基本工资的逻辑,保持与"空白"语义一致。

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 14:57:10 +08:00
parent 63a0a6934b
commit a5911d1874
6 changed files with 396 additions and 12 deletions
+191 -7
View File
@@ -59,6 +59,7 @@ calcBatchEntry(
options?: { // 可选覆盖
skipSocial?, // 跳过社保计算
overrideSocial?, // 手动覆盖社保值
prevDeferred?, // 上月递延扣款(次月补扣){ socialEmp, housingEmp, minWage }
}
)
```
@@ -131,6 +132,12 @@ socialEmp = Math.max(0, fullSocialEmp - deductedSocialEmp)
socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg)
housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp)
housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg)
// 叠加上月递延的社保/公积金(入职当月未扣完的部分,本月补扣)
if (options?.prevDeferred) {
socialEmp += options.prevDeferred.socialEmp || 0
housingEmp += options.prevDeferred.housingEmp || 0
}
```
### 关键设计:差额补扣机制
@@ -271,11 +278,18 @@ tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
---
## 六、实发工资
## 六、实发工资与最低工资保护
```typescript
netPay = totalPay - socialEmp - housingEmp - tax
// 应发 - 个人社保 - 个人公积金 - 个税
// 初始实发 = 应发 - 个人社保 - 个人公积金 - 个税 - 上月递延最低工资补扣
netPay = totalPay - socialEmp - housingEmp - tax - prevDeferredMinWage
// 最低工资保护(仅 REGULAR / TERMINATION 批次)
if (minWage > 0 && netPay < minWage) {
// 优先递延社保 → 递延公积金 → 递延最低工资补齐
// 详见第十五章「最低工资保护与递延扣款机制」
netPay = minWage
}
```
---
@@ -417,6 +431,7 @@ POST /batches/:batchId/unarchive
│SocialAccount│ │SpecialDeduction │ │ OvertimeRecord │
│+YearStandard│ │Record (按月) │ │ (加班费) │
│(社保比例) │ └──────────────────┘ └─────────────────┘
│+minWage │ │ │
└──────┬─────┘ │ │
│ │ │
▼ ▼ ▼
@@ -424,15 +439,23 @@ POST /batches/:batchId/unarchive
│ calcBatchEntry() │
│ │
│ 社保 = 应缴全额 - 已归档批次已扣(差额补扣) │
│ + 上月递延社保(次月补扣) │
│ 应发 = 基本工资 + 各项津贴 + 奖金 - 扣款 │
│ 个税 = 累计预扣法(从已归档批次取累计数据) │
│ 实发 = 应发 - 个人社保 - 个人公积金 - 个税 │
│ - 上月递延最低工资补扣 │
│ 最低工资保护:实发 < minWage 时 → 递延扣款补齐 │
│ 递延社保 → 递延公积金 → 递延最低工资补齐 │
└──────────────────────┬───────────────────────────────┘
┌────────────────┐
│ BatchEntry │
│ (批次条目) │
│ + minWage │
│ + minWageApplied│
│ + deferred* │
│ + prevDeferred*│
└───────┬────────┘
│ 归档
@@ -444,6 +467,11 @@ POST /batches/:batchId/unarchive
│ 发布
员工端可见
次月创建批次时:
getPrevDeferred() 读取上月 BatchEntry 的 deferred* 字段
→ 传入 calcBatchEntry 的 options.prevDeferred
→ 本月社保/公积金叠加补扣上月递延金额
```
---
@@ -454,6 +482,8 @@ POST /batches/:batchId/unarchive
|--------|--------|---------|------|
| 社保比例 | `SocialYearStandard` | accountId + 月份在生效区间 | 计算社保公积金 |
| 社保比例(回退) | `SocialInsuranceConfig` | orgId + city + 月份在生效区间 | 兼容旧数据 |
| 最低工资标准 | `SocialYearStandard.minWage` | accountId + 月份在生效区间 | 最低工资保护 |
| 最低工资标准(回退) | `SocialInsuranceConfig.minWage` | orgId + city + 月份在生效区间 | 兼容旧数据 |
| 社保基数 | `Employee.socialInsBase` | — | 优先于基本工资 |
| 公积金基数 | `Employee.housingFundBase` | — | 优先于基本工资 |
| 已扣社保 | `BatchEntry` | 当月已归档批次 | 差额补扣 |
@@ -467,6 +497,9 @@ POST /batches/:batchId/unarchive
| 基本工资 | `Payslip`(上月) | employeeId + 上月 | copy_last 模式 |
| 基本工资(回退) | `Employee.monthlySalary` | 解密 | 无上月工资条时 |
| 试用期工资 | `LaborContract.probationSalary` | 最新合同 | 试用期内的 baseSalary |
| 上月递延社保 | `BatchEntry.deferredSocialEmp` | 上月已归档批次 | 次月补扣 |
| 上月递延公积金 | `BatchEntry.deferredHousingEmp` | 上月已归档批次 | 次月补扣 |
| 上月递延最低工资补齐 | `BatchEntry.deferredMinWage` | 上月已归档批次 | 次月补扣 |
---
@@ -494,11 +527,16 @@ POST /batches/:batchId/unarchive
质量门禁(草稿状态自动检查):
- 实发为负的员工 → 红色警告
- 实发低于最低工资 → 红色警告
- 实发低于最低工资(未触发保护)→ 红色警告
- 最低工资保护已触发(递延扣款)→ 黄色警告
- 本月补扣上月递延 → 黄色警告
- 全零记录 → 黄色警告
表格可视化提示:
- **实发列**:最低工资保护触发时显示橙色 + ★标记,悬停显示补齐明细
- **递延扣款列**(新增):橙色"递延 ¥X"或蓝色"补扣 ¥X",悬停显示分项明细
- **风险列**:最低工资保护触发时显示橙色警告图标
### 步骤 2:归档锁定(ARCHIVED
**归档时后端自动执行**
@@ -514,7 +552,153 @@ POST /batches/:batchId/unarchive
---
## 十五、状态流转
## 十五、最低工资保护与递延扣款机制
### 背景
入职当月工资按天折算后,可能不足以扣除社保/公积金个人部分,导致实发为负。
《劳动法》第四十八条规定用人单位支付劳动者的工资不得低于当地最低工资标准。
### 最低工资标准配置
| 配置位置 | 表 | 字段 | 说明 |
|---------|---|------|------|
| 新账户体系 | `SocialYearStandard` | `minWage` | 按账户+年度设置,优先使用 |
| 旧配置表 | `SocialInsuranceConfig` | `minWage` | 兼容回退,按城市匹配 |
前端配置入口:社保配置页面 → 新建版本 → "最低工资标准"输入框(仅社保 Tab)
设为 0 表示不检查最低工资。
### 保护逻辑(calcBatchEntry 中执行)
```
初始实发 = 应发 - 社保个人 - 公积金个人 - 个税 - 上月递延最低工资补扣
if (minWage > 0 且 实发 < minWage) {
差额 = minWage - 实发
① 优先递延社保个人部分
if (差额 <= 社保个人) {
递延社保 = 差额
社保个人 -= 差额
实发 = minWage
}
② 社保不够,递延全部社保 + 部分公积金
else if (差额 <= 社保个人 + 公积金个人) {
递延社保 = 社保个人
递延公积金 = 差额 - 社保个人
社保个人 = 0
公积金个人 -= 递延公积金
实发 = minWage
}
③ 社保+公积金都不够,差额作为最低工资补齐递延
else {
递延社保 = 社保个人
递延公积金 = 公积金个人
递延最低工资补齐 = 差额 - 社保个人 - 公积金个人
社保个人 = 0
公积金个人 = 0
实发 = minWage
}
}
```
### minWageApplied 的含义
`minWageApplied` = 为让实发达到最低工资而减少的扣款总额,包含三部分:
| 组成 | 字段 | 含义 |
|------|------|------|
| 免扣社保 | `deferredSocialEmp` | 本该扣但没扣的社保个人部分 |
| 免扣公积金 | `deferredHousingEmp` | 本该扣但没扣的公积金个人部分 |
| 额外补齐 | `deferredMinWage` | 社保+公积金全免后仍不足的差额 |
| **合计** | `minWageApplied` | = 免扣社保 + 免扣公积金 + 额外补齐 |
**注意**`minWageApplied` 不是"额外补了这么多现金",而是"减少了这么多扣款"。
### 递延扣款的次月补扣
```
本月(入职当月):
BatchEntry 记录 deferredSocialEmp / deferredHousingEmp / deferredMinWage
实发 = minWage(保护后)
次月(创建批次时):
getPrevDeferred() 读取上月已归档批次的递延金额
calcBatchEntry 的 options.prevDeferred 传入
本月社保个人 = 当月应缴社保 + 上月递延社保
本月实发 = 应发 - (当月社保 + 上月递延社保) - (当月公积金 + 上月递延公积金) - 个税 - 上月递延最低工资补齐
BatchEntry 记录 prevDeferredSocialEmp / prevDeferredHousingEmp / prevDeferredMinWage
```
### 示例
**张三 8 月 20 日入职,月薪 12000,扣款 20000(测试数据)**
```
应发 = 12000 + 0 + 0 + (-500) - 20000 = -8500
社保个人(原)= 1575
公积金个人(原)= 1800
个税 = 0
初始实发 = -8500 - 1575 - 1800 - 0 = -11875
最低工资 = 2420
保护触发:
差额 = 2420 - (-11875) = 14295
递延社保 = 1575(全部免扣)
递延公积金 = 1800(全部免扣)
递延最低工资补齐 = 14295 - 1575 - 1800 = 10920
实发 = 2420
BatchEntry 记录:
minWage = 2420
minWageApplied = 14295
deferredSocialEmp = 1575
deferredHousingEmp = 1800
deferredMinWage = 10920
socialEmp = 0(当月不扣)
housingEmp = 0(当月不扣)
netPay = 2420
次月(9 月):
getPrevDeferred() 读取 → { socialEmp: 1575, housingEmp: 1800, minWage: 10920 }
9 月社保个人 = 当月应缴社保 + 1575
9 月实发 = 应发 - 9月社保(含补扣) - 9月公积金(含补扣) - 个税 - 10920
```
### 前端展示
| 展示位置 | 样式 | 内容 |
|---------|------|------|
| 实发列 | 橙色 + ★ | 触发最低工资保护时,悬停显示补齐明细 |
| 递延扣款列 | 橙色"递延 ¥X" | 当月递延,悬停显示分项(免扣社保/公积金/额外补齐) |
| 递延扣款列 | 蓝色"补扣 ¥X" | 次月补扣上月递延,悬停显示分项 |
| 风险列 | 橙色警告图标 | 最低工资保护触发 |
| 质量门禁 | 黄色提示 | "N 名员工实发已补齐到最低工资标准..." |
### prePayrollCheck 检查项(第 11/12 项)
```
// 11. 实发低于最低工资标准(剔除加班费后比较)
if (minWage > 0 且 可比收入 < minWage 且 实发 < minWage) {
if (minWageApplied > 0) {
→ WARNING: 最低工资保护已触发(递延扣款)
} else {
→ FAIL: 实发低于最低工资标准
}
}
// 12. 上月递延扣款待补扣
if (prevDeferred > 0) {
→ WARNING: 上月递延扣款已补扣
}
```
---
## 十六、状态流转
```
DRAFT(草稿,可编辑)
@@ -528,7 +712,7 @@ Payslip.publishStatus: PUBLISHED(员工端可见)
---
## 十、批次类型详解
## 十、批次类型详解
| 类型 | 说明 | 社保公积金 | 个税计算 | 员工来源 |
|------|------|-----------|---------|---------|
@@ -539,7 +723,7 @@ Payslip.publishStatus: PUBLISHED(员工端可见)
---
## 十、创建批次的 5 种数据初始化模式
## 十、创建批次的 5 种数据初始化模式
| 模式 | 说明 | 员工来源 | 数据来源 |
|------|------|---------|---------|
+1 -5
View File
@@ -405,11 +405,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
allowance = prevPayslip?.allowance || 0
deduction = prevPayslip?.deduction || 0
}
// blank_employees 和 blank_all: 所有金额默认 0
// blank_employees 模式下尝试从员工记录获取基本工资
if (mode === 'blank_employees' && emp.monthlySalary) {
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
}
// blank_employees 和 blank_all: 所有金额默认 0(不自动带出基本工资)
// 统一试用期判定(所有模式适用,SEVERANCE 除外)
// 试用期且 probationSalary > 0 → 覆盖 baseSalary 为试用期工资
+20
View File
@@ -233,6 +233,26 @@ router.post('/accounts/:accountId/standards', async (req: AuthRequest, res: Resp
} catch (err) { next(err) }
})
/**
* 快速更新当前年度标准的最低工资(无需新建版本)
*/
router.put('/accounts/:accountId/min-wage', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { accountId } = req.params
const { minWage } = req.body as { minWage: number }
if (minWage === undefined || minWage < 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: 'minWage 必须为非负数' } })
}
const account = await prisma.socialAccount.findFirst({ where: { id: accountId, orgId } })
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
const standard = await prisma.socialYearStandard.findFirst({ where: { accountId, isCurrent: true } })
if (!standard) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '当前年度标准不存在' } })
const updated = await prisma.socialYearStandard.update({ where: { id: standard.id }, data: { minWage } })
res.json({ success: true, data: updated })
} catch (err) { next(err) }
})
// 账户关联根部门(level=0)批量设置
router.put('/accounts/:id/departments', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
+3
View File
@@ -556,6 +556,9 @@ export const socialAccountApi = {
/** 按部门获取适用账户(选部门时自动带出) */
departmentAccount: (departmentId: string) =>
get(`/social/department-account/${departmentId}`).then(unwrap<any>()),
/** 快速更新当前年度标准的最低工资 */
updateMinWage: (accountId: string, minWage: number) =>
put(`/social/accounts/${accountId}/min-wage`, { minWage }).then(unwrap<any>()),
}
export const socialInsuranceApi = {
+43
View File
@@ -38,6 +38,7 @@ export default function SocialInsurance() {
const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7))
const [monthlyProcessed, setMonthlyProcessed] = useState(false)
const [processStatus, setProcessStatus] = useState<{ social: any; housing: any } | null>(null)
const [minWageInput, setMinWageInput] = useState('0')
const [newVersion, setNewVersion] = useState<any>({
effectiveFrom: new Date().toISOString().slice(0, 7),
city: '北京',
@@ -238,6 +239,27 @@ export default function SocialInsurance() {
},
})
// 快速更新最低工资(无需新建版本)
const updateMinWageMutation = useMutation({
mutationFn: () => {
const accountId = selectedAccountId || activeConfig?.accountId
if (!accountId) throw new Error('未选择账户')
return socialAccountApi.updateMinWage(accountId, Number(minWageInput) || 0)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-config'] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
toast.success('最低工资标准已更新')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '更新失败'),
})
// 当前配置变化时同步最低工资输入框
useEffect(() => {
setMinWageInput(String(activeConfig?.minWage || 0))
}, [activeConfig?.minWage])
const aiSuggestMut = useMutation<any, any, { city: string; effectiveFrom: string; type: 'social' | 'housing' }>({
mutationFn: async (vars: { city: string; effectiveFrom: string; type: 'social' | 'housing' }) => {
return await socialInsuranceApi.aiSuggest(vars)
@@ -524,6 +546,27 @@ export default function SocialInsurance() {
{activeConfig.minWage > 0 && (
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium text-primary">¥{fmt(activeConfig.minWage)}</span></div>
)}
{/* 最低工资快速设置(社保 Tab,当前配置区域可直接编辑) */}
<div className="flex justify-between items-center border-b pb-1.5">
<span className="text-gray-500"></span>
<div className="flex items-center gap-2">
<Input
type="number"
value={minWageInput}
onChange={(e) => setMinWageInput(e.target.value)}
placeholder="0"
className="w-24 h-7 text-sm text-right"
/>
<Button
size="sm"
variant="secondary"
onClick={() => updateMinWageMutation.mutate()}
disabled={updateMinWageMutation.isPending}
>
{updateMinWageMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</div>
{Array.isArray(activeConfig.extraInsurances) && activeConfig.extraInsurances.map((ins: any, idx: number) => (
<div key={idx} className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">{ins.name}(/)</span>
@@ -0,0 +1,138 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { socialAccountApi } from '../../lib/api-services'
import api from '../../lib/api'
import Button from '../../components/ui/Button'
import { Input, Label, Select } from '../../components/ui/Input'
import Modal from '../../components/ui/Modal'
/**
* 账户新建/编辑弹窗组件
* 从 Settings.tsx 迁移,用于在社保公积金页面中管理账户
*/
export function AccountFormModal({ type, account, onClose, onSubmit, saving }: {
type: string
account: any
onClose: () => void
onSubmit: (data: any, departmentIds?: string[]) => void
saving: boolean
}) {
const [name, setName] = useState(account?.name || '')
const [city, setCity] = useState(account?.city || '')
const [accountNo, setAccountNo] = useState(account?.accountNo || '')
const [orgName, setOrgName] = useState(account?.orgName || '')
const [orgCode, setOrgCode] = useState(account?.orgCode || '')
const [bankName, setBankName] = useState(account?.bankName || '')
const [bankAccount, setBankAccount] = useState(account?.bankAccount || '')
const [accountTypeVal, setAccountTypeVal] = useState(account?.accountType || 'BASIC')
const [isDefault, setIsDefault] = useState(account?.isDefault || false)
const [remark, setRemark] = useState(account?.remark || '')
const [selectedDeptIds, setSelectedDeptIds] = useState<string[]>([])
// 加载 level=0 根部门列表
const { data: rootDepartments = [] } = useQuery<any[]>({
queryKey: ['departments'],
queryFn: () => api.get('/departments').then(r => r.data),
})
const rootDepts = rootDepartments.filter((d: any) => d.level === 0)
// 编辑时加载已关联部门
useEffect(() => {
if (account?.id) {
socialAccountApi.accountDepartments(account.id).then((depts: any[]) => {
setSelectedDeptIds(depts.map((d: any) => d.id))
}).catch(() => {})
}
}, [account?.id])
const toggleDept = (id: string) => {
setSelectedDeptIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
return (
<Modal open onClose={onClose} title={account ? '编辑账户' : '新建账户'} size="md">
<div className="space-y-4">
<div>
<Label> *</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:北京总公司社保账户" />
</div>
<div>
<Label> *</Label>
<Input value={city} onChange={(e) => setCity(e.target.value)} placeholder="如:北京" />
</div>
<div>
<Label>{type === 'SOCIAL' ? '社保登记号' : '公积金单位账号'}</Label>
<Input value={accountNo} onChange={(e) => setAccountNo(e.target.value)} />
</div>
{type === 'HOUSING' && (
<>
<div>
<Label></Label>
<Select value={accountTypeVal} onChange={(e) => setAccountTypeVal(e.target.value)}>
<option value="BASIC"></option>
<option value="SUPPLEMENTARY"></option>
</Select>
</div>
<div>
<Label></Label>
<Input value={bankName} onChange={(e) => setBankName(e.target.value)} placeholder="如:工商银行北京分行" />
</div>
<div>
<Label></Label>
<Input value={bankAccount} onChange={(e) => setBankAccount(e.target.value)} />
</div>
</>
)}
<div>
<Label></Label>
<Input value={orgName} onChange={(e) => setOrgName(e.target.value)} placeholder="子公司/分公司名称" />
</div>
<div>
<Label></Label>
<Input value={orgCode} onChange={(e) => setOrgCode(e.target.value)} />
</div>
<div>
<Label></Label>
<Input value={remark} onChange={(e) => setRemark(e.target.value)} />
</div>
{/* 关联根部门 */}
<div>
<Label>//</Label>
<p className="text-xs text-gray-400 mb-2">使</p>
{rootDepts.length === 0 ? (
<div className="text-xs text-gray-400"></div>
) : (
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
{rootDepts.map((d: any) => (
<label key={d.id} className="flex items-center gap-2 text-sm py-1">
<input
type="checkbox"
checked={selectedDeptIds.includes(d.id)}
onChange={() => toggleDept(d.id)}
/>
<span>{d.name}</span>
{d._count?.employees > 0 && <span className="text-xs text-gray-400">({d._count.employees})</span>}
</label>
))}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
</label>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button
disabled={!name || !city || saving}
onClick={() => onSubmit({ name, city, accountNo, orgName, orgCode, bankName, bankAccount, accountType: accountTypeVal, isDefault, remark }, selectedDeptIds)}
>
{saving ? '保存中...' : '保存'}
</Button>
</div>
</div>
</Modal>
)
}